diff --git a/database/migrations/Migration20260701130000_addSlidersTable.ts b/database/migrations/Migration20260701130000_addSlidersTable.ts new file mode 100644 index 0000000..7dae87a --- /dev/null +++ b/database/migrations/Migration20260701130000_addSlidersTable.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260701130000_addSlidersTable extends Migration { + + override async up(): Promise { + this.addSql(` + create table "sliders" ( + "id" char(26) not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + "restaurant_id" char(26) not null, + "image_url" varchar(255) not null, + "title" varchar(255) null, + "link" varchar(255) null, + "order" int not null default 0, + "is_active" boolean not null default true, + constraint "sliders_pkey" primary key ("id") + ); + `); + this.addSql(`create index "sliders_created_at_index" on "sliders" ("created_at");`); + this.addSql(`create index "sliders_deleted_at_index" on "sliders" ("deleted_at");`); + this.addSql(`create index "sliders_restaurant_id_index" on "sliders" ("restaurant_id");`); + this.addSql(`create index "sliders_restaurant_id_is_active_index" on "sliders" ("restaurant_id", "is_active");`); + this.addSql(` + alter table "sliders" + add constraint "sliders_restaurant_id_foreign" + foreign key ("restaurant_id") references "restaurants" ("id") on update cascade; + `); + } + + override async down(): Promise { + this.addSql(`alter table "sliders" drop constraint if exists "sliders_restaurant_id_foreign";`); + this.addSql(`drop table if exists "sliders" cascade;`); + } + +} diff --git a/src/app.module.ts b/src/app.module.ts index 2013949..b8b8bc2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { PagerModule } from './modules/pager/pager.module'; import { ContactModule } from './modules/contact/contact.module'; import { InventoryModule } from './modules/inventory/inventory.module'; import { IconsModule } from './modules/icons/icons.module'; +import { SliderModule } from './modules/slider/slider.module'; import { CacheModule } from '@nestjs/cache-manager'; import { cacheConfig } from './config/cache.config'; @@ -59,6 +60,7 @@ import { cacheConfig } from './config/cache.config'; ContactModule, InventoryModule, IconsModule, + SliderModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index ab6ff53..32bb649 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -763,6 +763,10 @@ export const enum DeliveryMessage { DELIVERY_METHOD_NOT_FOUND = 'روش ارسال یافت نشد', } +export const enum SliderMessage { + NOT_FOUND = 'اسلایدر یافت نشد', +} + export const enum InventoryMessage { AVAILABLE_STOCK_EXCEEDS_TOTAL = 'موجودی موجود نمی‌تواند از موجودی کل بیشتر باشد', FOOD_NOT_FOUND = 'غذا یافت نشد', diff --git a/src/common/enums/permission.enum.ts b/src/common/enums/permission.enum.ts index 5840a8f..63060da 100644 --- a/src/common/enums/permission.enum.ts +++ b/src/common/enums/permission.enum.ts @@ -31,6 +31,7 @@ export enum Permission { MANAGE_SCHEDULES = 'manage_schedules', MANAGE_REPORTS = 'manage_reports', MANAGE_CONTACTS = 'manage_contacts', + MANAGE_SLIDERS = 'manage_sliders', NEW_ORDER_NOTIFICATION='new_order_notification', PAGER_NOTIFICATION='pager_notification' @@ -58,6 +59,7 @@ export const PermissionTitles: Record = { [Permission.MANAGE_SCHEDULES]: 'مدیریت برنامه‌ها', [Permission.MANAGE_REPORTS]: 'مدیریت گزارشات', [Permission.MANAGE_CONTACTS]: 'مدیریت تماس‌های کاربران', + [Permission.MANAGE_SLIDERS]: 'مدیریت اسلایدرها', [Permission.NEW_ORDER_NOTIFICATION]: "نوتیف سفارش جدید", [Permission.PAGER_NOTIFICATION]: "نوتیف پیجر جدید" }; diff --git a/src/modules/slider/controllers/slider.controller.ts b/src/modules/slider/controllers/slider.controller.ts new file mode 100644 index 0000000..d3d4cdd --- /dev/null +++ b/src/modules/slider/controllers/slider.controller.ts @@ -0,0 +1,87 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiParam, + ApiBody, + ApiBearerAuth, + ApiHeader, +} from '@nestjs/swagger'; +import { SliderService } from '../providers/slider.service'; +import { CreateSliderDto } from '../dto/create-slider.dto'; +import { UpdateSliderDto } from '../dto/update-slider.dto'; +import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; +import { RestId } from 'src/common/decorators/rest-id.decorator'; +import { API_HEADER_SLUG } from 'src/common/constants'; +import { Permission } from 'src/common/enums/permission.enum'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; + +@ApiTags('slider') +@Controller() +export class SliderController { + constructor(private readonly sliderService: SliderService) {} + + @Get('public/sliders/restaurant/:slug') + @ApiOperation({ summary: 'Get active sliders by restaurant slug' }) + @ApiHeader(API_HEADER_SLUG) + @ApiParam({ name: 'slug', required: true, description: 'Restaurant slug' }) + findActiveByRestaurant(@Param('slug') slug: string) { + return this.sliderService.findActiveByRestaurantSlug(slug); + } + + /*** Admin ***/ + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_SLIDERS) + @Post('admin/sliders') + @ApiOperation({ summary: 'Create a slider for the restaurant' }) + @ApiBody({ type: CreateSliderDto }) + create(@Body() dto: CreateSliderDto, @RestId() restId: string) { + return this.sliderService.create(restId, dto); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_SLIDERS) + @Get('admin/sliders') + @ApiOperation({ summary: 'Get all sliders for the restaurant' }) + findAll(@RestId() restId: string) { + return this.sliderService.findByRestaurantId(restId); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_SLIDERS) + @Get('admin/sliders/:sliderId') + @ApiOperation({ summary: 'Get a slider by ID' }) + @ApiParam({ name: 'sliderId', description: 'Slider ID' }) + findOne(@Param('sliderId') sliderId: string, @RestId() restId: string) { + return this.sliderService.findOneOrFail(restId, sliderId); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_SLIDERS) + @Patch('admin/sliders/:sliderId') + @ApiOperation({ summary: 'Update a slider' }) + @ApiParam({ name: 'sliderId', description: 'Slider ID' }) + @ApiBody({ type: UpdateSliderDto }) + update( + @Param('sliderId') sliderId: string, + @Body() dto: UpdateSliderDto, + @RestId() restId: string, + ) { + return this.sliderService.update(restId, sliderId, dto); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_SLIDERS) + @Delete('admin/sliders/:sliderId') + @ApiOperation({ summary: 'Delete a slider' }) + @ApiParam({ name: 'sliderId', description: 'Slider ID' }) + remove(@Param('sliderId') sliderId: string, @RestId() restId: string) { + return this.sliderService.remove(restId, sliderId); + } +} diff --git a/src/modules/slider/dto/create-slider.dto.ts b/src/modules/slider/dto/create-slider.dto.ts new file mode 100644 index 0000000..e189a0b --- /dev/null +++ b/src/modules/slider/dto/create-slider.dto.ts @@ -0,0 +1,33 @@ +import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class CreateSliderDto { + @ApiProperty({ example: 'https://example.com/slider.jpg', description: 'Slider image URL' }) + @IsNotEmpty() + @IsString() + imageUrl!: string; + + @ApiPropertyOptional({ example: 'پیشنهاد ویژه', description: 'Slider title' }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ example: 'https://example.com/promo', description: 'Optional link when slider is clicked' }) + @IsOptional() + @IsString() + link?: string; + + @ApiPropertyOptional({ example: 0, description: 'Display order for sorting sliders' }) + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + order?: number; + + @ApiPropertyOptional({ example: true, description: 'Whether the slider is active' }) + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + isActive?: boolean; +} diff --git a/src/modules/slider/dto/update-slider.dto.ts b/src/modules/slider/dto/update-slider.dto.ts new file mode 100644 index 0000000..51edf53 --- /dev/null +++ b/src/modules/slider/dto/update-slider.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateSliderDto } from './create-slider.dto'; + +export class UpdateSliderDto extends PartialType(CreateSliderDto) {} diff --git a/src/modules/slider/entities/slider.entity.ts b/src/modules/slider/entities/slider.entity.ts new file mode 100644 index 0000000..2527d65 --- /dev/null +++ b/src/modules/slider/entities/slider.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; + +@Entity({ tableName: 'sliders' }) +@Index({ properties: ['restaurant', 'isActive'] }) +@Index({ properties: ['restaurant'] }) +export class Slider extends BaseEntity { + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @Property() + imageUrl!: string; + + @Property({ nullable: true }) + title?: string; + + @Property({ nullable: true }) + link?: string; + + @Property({ type: 'integer', default: 0 }) + order: number = 0; + + @Property({ default: true }) + isActive: boolean = true; +} diff --git a/src/modules/slider/providers/slider.service.ts b/src/modules/slider/providers/slider.service.ts new file mode 100644 index 0000000..cbc6b2d --- /dev/null +++ b/src/modules/slider/providers/slider.service.ts @@ -0,0 +1,74 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager, RequiredEntityData } from '@mikro-orm/postgresql'; +import { Slider } from '../entities/slider.entity'; +import { SliderRepository } from '../repositories/slider.repository'; +import { CreateSliderDto } from '../dto/create-slider.dto'; +import { UpdateSliderDto } from '../dto/update-slider.dto'; +import { SliderMessage } from 'src/common/enums/message.enum'; +import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service'; + +@Injectable() +export class SliderService { + constructor( + private readonly sliderRepository: SliderRepository, + private readonly restService: RestaurantsService, + private readonly em: EntityManager, + ) {} + + async create(restId: string, dto: CreateSliderDto): Promise { + const restaurant = await this.restService.findOneOrFail(restId); + + const data: RequiredEntityData = { + restaurant, + imageUrl: dto.imageUrl, + title: dto.title, + link: dto.link, + order: dto.order ?? 0, + isActive: dto.isActive ?? true, + }; + + const slider = this.sliderRepository.create(data); + await this.em.persistAndFlush(slider); + return slider; + } + + async findByRestaurantId(restId: string): Promise { + return this.sliderRepository.find( + { restaurant: { id: restId } }, + { orderBy: { order: 'asc', createdAt: 'asc' } }, + ); + } + + async findActiveByRestaurantSlug(slug: string): Promise { + return this.sliderRepository.find( + { restaurant: { slug, isActive: true }, isActive: true }, + { orderBy: { order: 'asc', createdAt: 'asc' } }, + ); + } + + async findOneOrFail(restId: string, sliderId: string): Promise { + const slider = await this.sliderRepository.findOne({ + id: sliderId, + restaurant: { id: restId }, + }); + + if (!slider) { + throw new NotFoundException(SliderMessage.NOT_FOUND); + } + + return slider; + } + + async update(restId: string, sliderId: string, dto: UpdateSliderDto): Promise { + const slider = await this.findOneOrFail(restId, sliderId); + this.em.assign(slider, dto); + await this.em.persistAndFlush(slider); + return slider; + } + + async remove(restId: string, sliderId: string): Promise { + const slider = await this.findOneOrFail(restId, sliderId); + slider.deletedAt = new Date(); + await this.em.persistAndFlush(slider); + } +} diff --git a/src/modules/slider/repositories/slider.repository.ts b/src/modules/slider/repositories/slider.repository.ts new file mode 100644 index 0000000..34bbde9 --- /dev/null +++ b/src/modules/slider/repositories/slider.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { Slider } from '../entities/slider.entity'; + +@Injectable() +export class SliderRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Slider); + } +} diff --git a/src/modules/slider/slider.module.ts b/src/modules/slider/slider.module.ts new file mode 100644 index 0000000..4f7ea07 --- /dev/null +++ b/src/modules/slider/slider.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { JwtModule } from '@nestjs/jwt'; +import { Slider } from './entities/slider.entity'; +import { SliderController } from './controllers/slider.controller'; +import { SliderService } from './providers/slider.service'; +import { SliderRepository } from './repositories/slider.repository'; +import { RestaurantsModule } from '../restaurants/restaurants.module'; + +@Module({ + imports: [MikroOrmModule.forFeature([Slider]), JwtModule, RestaurantsModule], + controllers: [SliderController], + providers: [SliderService, SliderRepository], + exports: [SliderService, SliderRepository], +}) +export class SliderModule {} diff --git a/src/seeders/data/roles.data.ts b/src/seeders/data/roles.data.ts index 312cae4..65d961b 100644 --- a/src/seeders/data/roles.data.ts +++ b/src/seeders/data/roles.data.ts @@ -16,6 +16,7 @@ export const rolesData: RoleConfig[] = [ name === Permission.MANAGE_SCHEDULES || name === Permission.MANAGE_PAGER || name === Permission.MANAGE_CONTACTS || + name === Permission.MANAGE_SLIDERS || name === Permission.MANAGE_ROLES || name === Permission.MANAGE_SETTINGS ||