Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { BannersService } from './banners.service';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
@Controller()
export class BannersController {
constructor(private readonly bannersService: BannersService) {}
@AuthType('any')
@Get('banners')
publicList(@Query('slot') slot?: string) {
return this.bannersService.list(slot, true);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Get('admin/banners')
adminList(@Query('slot') slot?: string) {
return this.bannersService.list(slot);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Post('admin/banners')
create(@Body() dto: CreateBannerDto) {
return this.bannersService.create(dto);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Patch('admin/banners/:id')
update(@Param('id') id: string, @Body() dto: UpdateBannerDto) {
return this.bannersService.update(id, dto);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Delete('admin/banners/:id')
remove(@Param('id') id: string) {
return this.bannersService.remove(id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BannersController } from './banners.controller';
import { BannersService } from './banners.service';
@Module({
controllers: [BannersController],
providers: [BannersService],
exports: [BannersService],
})
export class BannersModule {}
+126
View File
@@ -0,0 +1,126 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']);
@Injectable()
export class BannersService {
constructor(private readonly prisma: PrismaService) {}
async list(slot?: string, activeOnly = false) {
const rows = await this.prisma.appBanner.findMany({
where: {
...(slot ? { slot } : {}),
...(activeOnly ? { isActive: true } : {}),
},
orderBy: [{ slot: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'desc' }],
});
return rows.map((row) => this.map(row));
}
async create(dto: CreateBannerDto) {
const slot = this.normalizeSlot(dto.slot);
const banner = await this.prisma.appBanner.create({
data: {
slot,
title: dto.title.trim(),
subtitle: dto.subtitle?.trim() || null,
ctaLabel: dto.ctaLabel?.trim() || null,
ctaLink: dto.ctaLink?.trim() || null,
imageUrl: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
},
});
return this.map(banner);
}
async update(id: string, dto: UpdateBannerDto) {
await this.ensure(id);
const banner = await this.prisma.appBanner.update({
where: { id },
data: {
slot: dto.slot === undefined ? undefined : this.normalizeSlot(dto.slot),
title: dto.title?.trim(),
subtitle:
dto.subtitle === undefined ? undefined : dto.subtitle?.trim() || null,
ctaLabel:
dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null,
ctaLink:
dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null,
imageUrl:
dto.imageUrl === undefined
? undefined
: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
});
return this.map(banner);
}
async remove(id: string) {
await this.ensure(id);
await this.prisma.appBanner.delete({ where: { id } });
return { ok: true };
}
private async ensure(id: string) {
const row = await this.prisma.appBanner.findUnique({ where: { id } });
if (!row) throw new NotFoundException('بنر یافت نشد');
return row;
}
private normalizeSlot(slot: string) {
const value = slot.trim();
if (!ALLOWED_SLOTS.has(value)) {
throw new BadRequestException('بخش بنر نامعتبر است');
}
return value;
}
private normalizeImage(value?: string | null) {
if (value == null || value === '') return null;
const image = value.trim();
if (
!image.startsWith('data:image/') &&
!image.startsWith('https://') &&
!image.startsWith('http://')
) {
throw new BadRequestException('فرمت تصویر بنر نامعتبر است');
}
if (image.startsWith('data:image/') && image.length > 1_600_000) {
throw new BadRequestException('حجم تصویر بنر خیلی زیاد است');
}
return image;
}
private map(row: {
id: string;
slot: string;
title: string;
subtitle: string | null;
ctaLabel: string | null;
ctaLink: string | null;
imageUrl: string | null;
displayOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
slot: row.slot,
title: row.title,
subtitle: row.subtitle ?? '',
ctaLabel: row.ctaLabel ?? '',
ctaLink: row.ctaLink ?? '',
imageUrl: row.imageUrl ?? '',
displayOrder: row.displayOrder,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+83
View File
@@ -0,0 +1,83 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateBannerDto {
@IsString()
@MinLength(2)
slot!: string;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@IsString()
ctaLabel?: string | null;
@IsOptional()
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateBannerDto {
@IsOptional()
@IsString()
@MinLength(2)
slot?: string;
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@IsString()
ctaLabel?: string | null;
@IsOptional()
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}