Initialize the API and add managed app banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user