From 442de8582ff5ff9b1ac8bc77a9e9cfcfd1286cb6 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 30 Jun 2026 11:12:27 +0330 Subject: [PATCH] sent messages --- src/app.module.ts | 2 + .../1720200000000-AddSentMessages.ts | 116 ++++++++++++++ .../dto/create-sent-message.dto.ts | 35 +++++ .../dto/find-sent-messages-query.dto.ts | 20 +++ .../dto/update-sent-message.dto.ts | 7 + .../entities/sent-message.entity.ts | 65 ++++++++ .../enums/sent-message-status.enum.ts | 5 + src/sent-messages/sent-messages.controller.ts | 63 ++++++++ src/sent-messages/sent-messages.module.ts | 25 +++ src/sent-messages/sent-messages.service.ts | 143 ++++++++++++++++++ 10 files changed, 481 insertions(+) create mode 100644 src/database/migrations/1720200000000-AddSentMessages.ts create mode 100644 src/sent-messages/dto/create-sent-message.dto.ts create mode 100644 src/sent-messages/dto/find-sent-messages-query.dto.ts create mode 100644 src/sent-messages/dto/update-sent-message.dto.ts create mode 100644 src/sent-messages/entities/sent-message.entity.ts create mode 100644 src/sent-messages/enums/sent-message-status.enum.ts create mode 100644 src/sent-messages/sent-messages.controller.ts create mode 100644 src/sent-messages/sent-messages.module.ts create mode 100644 src/sent-messages/sent-messages.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index dc7151f..ca0c098 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { getTypeOrmOptions } from './database/typeorm-options'; import { ReservesModule } from './reserves/reserves.module'; import { SalonsModule } from './salons/salons.module'; import { ServicesModule } from './services/services.module'; +import { SentMessagesModule } from './sent-messages/sent-messages.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; @@ -29,6 +30,7 @@ import { UsersModule } from './users/users.module'; ReservesModule, SuggestionsModule, SmsTemplatesModule, + SentMessagesModule, ], controllers: [], providers: [], diff --git a/src/database/migrations/1720200000000-AddSentMessages.ts b/src/database/migrations/1720200000000-AddSentMessages.ts new file mode 100644 index 0000000..0c13f25 --- /dev/null +++ b/src/database/migrations/1720200000000-AddSentMessages.ts @@ -0,0 +1,116 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddSentMessages1720200000000 implements MigrationInterface { + name = 'AddSentMessages1720200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "sent_messages_status_enum" AS ENUM( + 'pending', + 'sent', + 'failed' + ) + `); + + await queryRunner.createTable( + new Table({ + name: 'sent_messages', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'customerId', type: 'uuid', isNullable: true }, + { name: 'reserveId', type: 'uuid', isNullable: true }, + { name: 'mobile', type: 'varchar', length: '11' }, + { name: 'body', type: 'text' }, + { + name: 'status', + type: 'sent_messages_status_enum', + default: `'pending'`, + }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'sent_messages', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'sent_messages', + new TableForeignKey({ + columnNames: ['customerId'], + referencedTableName: 'customers', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createForeignKey( + 'sent_messages', + new TableForeignKey({ + columnNames: ['reserveId'], + referencedTableName: 'reserves', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createIndex( + 'sent_messages', + new TableIndex({ + name: 'IDX_sent_messages_salonId_createdAt', + columnNames: ['salonId', 'createdAt'], + }), + ); + + await queryRunner.createIndex( + 'sent_messages', + new TableIndex({ + name: 'IDX_sent_messages_customerId', + columnNames: ['customerId'], + }), + ); + + await queryRunner.createIndex( + 'sent_messages', + new TableIndex({ + name: 'IDX_sent_messages_reserveId', + columnNames: ['reserveId'], + }), + ); + + await queryRunner.createIndex( + 'sent_messages', + new TableIndex({ + name: 'IDX_sent_messages_status', + columnNames: ['status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('sent_messages', true); + await queryRunner.query(`DROP TYPE "sent_messages_status_enum"`); + } +} diff --git a/src/sent-messages/dto/create-sent-message.dto.ts b/src/sent-messages/dto/create-sent-message.dto.ts new file mode 100644 index 0000000..22532ff --- /dev/null +++ b/src/sent-messages/dto/create-sent-message.dto.ts @@ -0,0 +1,35 @@ +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, +} from 'class-validator'; +import { SentMessageStatus } from '../enums/sent-message-status.enum'; + +export class CreateSentMessageDto { + @IsUUID() + salonId: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + reserveId?: string; + + @Matches(/^09\d{9}$/) + mobile: string; + + @IsString() + @IsNotEmpty() + @MaxLength(1000) + body: string; + + @IsOptional() + @IsEnum(SentMessageStatus) + status?: SentMessageStatus; +} diff --git a/src/sent-messages/dto/find-sent-messages-query.dto.ts b/src/sent-messages/dto/find-sent-messages-query.dto.ts new file mode 100644 index 0000000..38cd195 --- /dev/null +++ b/src/sent-messages/dto/find-sent-messages-query.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { SentMessageStatus } from '../enums/sent-message-status.enum'; + +export class FindSentMessagesQueryDto { + @IsOptional() + @IsUUID() + salonId?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + reserveId?: string; + + @IsOptional() + @IsEnum(SentMessageStatus) + status?: SentMessageStatus; +} diff --git a/src/sent-messages/dto/update-sent-message.dto.ts b/src/sent-messages/dto/update-sent-message.dto.ts new file mode 100644 index 0000000..838b6c9 --- /dev/null +++ b/src/sent-messages/dto/update-sent-message.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { SentMessageStatus } from '../enums/sent-message-status.enum'; + +export class UpdateSentMessageDto { + @IsEnum(SentMessageStatus) + status: SentMessageStatus; +} diff --git a/src/sent-messages/entities/sent-message.entity.ts b/src/sent-messages/entities/sent-message.entity.ts new file mode 100644 index 0000000..938e9b9 --- /dev/null +++ b/src/sent-messages/entities/sent-message.entity.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Customer } from '../../customers/entities/customer.entity'; +import { Reserve } from '../../reserves/entities/reserve.entity'; +import { Salon } from '../../salons/entities/salon.entity'; +import { SentMessageStatus } from '../enums/sent-message-status.enum'; + +@Entity('sent_messages') +@Index(['salonId', 'createdAt']) +@Index(['customerId']) +@Index(['reserveId']) +@Index(['status']) +export class SentMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column({ type: 'uuid', nullable: true }) + customerId: string | null; + + @ManyToOne(() => Customer, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'customerId' }) + customer: Customer | null; + + @Column({ type: 'uuid', nullable: true }) + reserveId: string | null; + + @ManyToOne(() => Reserve, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'reserveId' }) + reserve: Reserve | null; + + @Column({ length: 11 }) + mobile: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ + type: 'enum', + enum: SentMessageStatus, + enumName: 'sent_messages_status_enum', + default: SentMessageStatus.PENDING, + }) + status: SentMessageStatus; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/sent-messages/enums/sent-message-status.enum.ts b/src/sent-messages/enums/sent-message-status.enum.ts new file mode 100644 index 0000000..fbb9913 --- /dev/null +++ b/src/sent-messages/enums/sent-message-status.enum.ts @@ -0,0 +1,5 @@ +export enum SentMessageStatus { + PENDING = 'pending', + SENT = 'sent', + FAILED = 'failed', +} diff --git a/src/sent-messages/sent-messages.controller.ts b/src/sent-messages/sent-messages.controller.ts new file mode 100644 index 0000000..406e10b --- /dev/null +++ b/src/sent-messages/sent-messages.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateSentMessageDto } from './dto/create-sent-message.dto'; +import { FindSentMessagesQueryDto } from './dto/find-sent-messages-query.dto'; +import { UpdateSentMessageDto } from './dto/update-sent-message.dto'; +import { SentMessagesService } from './sent-messages.service'; + +@Controller('sent-messages') +@UseGuards(RolesGuard) +export class SentMessagesController { + constructor(private readonly sentMessagesService: SentMessagesService) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Post() + create( + @Body() createDto: CreateSentMessageDto, + @CurrentUser() user: AuthUser, + ) { + return this.sentMessagesService.create(createDto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Get() + findAll( + @Query() query: FindSentMessagesQueryDto, + @CurrentUser() user: AuthUser, + ) { + return this.sentMessagesService.findAll(user, query); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Get(':id') + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.sentMessagesService.findOne(id, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDto: UpdateSentMessageDto, + @CurrentUser() user: AuthUser, + ) { + return this.sentMessagesService.update(id, updateDto, user); + } +} diff --git a/src/sent-messages/sent-messages.module.ts b/src/sent-messages/sent-messages.module.ts new file mode 100644 index 0000000..13da71c --- /dev/null +++ b/src/sent-messages/sent-messages.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { CustomersModule } from '../customers/customers.module'; +import { ReservesModule } from '../reserves/reserves.module'; +import { SalonsModule } from '../salons/salons.module'; +import { SentMessage } from './entities/sent-message.entity'; +import { SentMessagesController } from './sent-messages.controller'; +import { SentMessagesService } from './sent-messages.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SentMessage]), + AuthorizationModule, + AuthGuardsModule, + SalonsModule, + CustomersModule, + ReservesModule, + ], + controllers: [SentMessagesController], + providers: [SentMessagesService], + exports: [SentMessagesService], +}) +export class SentMessagesModule {} diff --git a/src/sent-messages/sent-messages.service.ts b/src/sent-messages/sent-messages.service.ts new file mode 100644 index 0000000..b982a5a --- /dev/null +++ b/src/sent-messages/sent-messages.service.ts @@ -0,0 +1,143 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthorizationService } from '../authorization/authorization.service'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { CustomersService } from '../customers/customers.service'; +import { ReservesService } from '../reserves/reserves.service'; +import { SalonsService } from '../salons/salons.service'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateSentMessageDto } from './dto/create-sent-message.dto'; +import { FindSentMessagesQueryDto } from './dto/find-sent-messages-query.dto'; +import { UpdateSentMessageDto } from './dto/update-sent-message.dto'; +import { SentMessage } from './entities/sent-message.entity'; + +@Injectable() +export class SentMessagesService { + constructor( + @InjectRepository(SentMessage) + private readonly sentMessagesRepository: Repository, + private readonly salonsService: SalonsService, + private readonly customersService: CustomersService, + private readonly reservesService: ReservesService, + private readonly authorizationService: AuthorizationService, + ) {} + + async create( + createDto: CreateSentMessageDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(createDto.salonId); + this.ensureCanAccessSalon(user, salon.ownerId); + + if (createDto.customerId) { + await this.customersService.findOne(createDto.customerId); + } + + if (createDto.reserveId) { + const reserve = await this.reservesService.findOne( + createDto.reserveId, + user, + ); + if (reserve.salonId !== createDto.salonId) { + throw new BadRequestException( + 'Reserve does not belong to the selected salon', + ); + } + } + + const message = this.sentMessagesRepository.create({ + salonId: createDto.salonId, + customerId: createDto.customerId ?? null, + reserveId: createDto.reserveId ?? null, + mobile: createDto.mobile, + body: createDto.body, + status: createDto.status, + }); + + const saved = await this.sentMessagesRepository.save(message); + return this.findOne(saved.id, user); + } + + async findAll( + user: AuthUser, + query: FindSentMessagesQueryDto, + ): Promise { + const qb = this.sentMessagesRepository + .createQueryBuilder('message') + .leftJoinAndSelect('message.salon', 'salon') + .leftJoinAndSelect('message.customer', 'customer') + .leftJoinAndSelect('customer.user', 'customerUser') + .leftJoinAndSelect('message.reserve', 'reserve') + .orderBy('message.createdAt', 'DESC'); + + if (query.salonId) { + qb.andWhere('message.salonId = :salonId', { salonId: query.salonId }); + } + if (query.customerId) { + qb.andWhere('message.customerId = :customerId', { + customerId: query.customerId, + }); + } + if (query.reserveId) { + qb.andWhere('message.reserveId = :reserveId', { + reserveId: query.reserveId, + }); + } + if (query.status) { + qb.andWhere('message.status = :status', { status: query.status }); + } + + if (!this.authorizationService.isAdmin(user)) { + if (user.role === UserRole.SALON) { + qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id }); + } else { + throw new ForbiddenException(); + } + } + + return qb.getMany(); + } + + async findOne(id: string, user: AuthUser): Promise { + const message = await this.sentMessagesRepository.findOne({ + where: { id }, + relations: { + salon: true, + customer: { user: true }, + reserve: true, + }, + }); + if (!message) { + throw new NotFoundException(`Sent message #${id} not found`); + } + this.ensureCanAccessSalon(user, message.salon.ownerId); + return message; + } + + async update( + id: string, + updateDto: UpdateSentMessageDto, + user: AuthUser, + ): Promise { + const message = await this.findOne(id, user); + message.status = updateDto.status; + await this.sentMessagesRepository.save(message); + return this.findOne(id, user); + } + + private ensureCanAccessSalon(user: AuthUser, ownerId: string): void { + if (this.authorizationService.isAdmin(user)) { + return; + } + if (user.role === UserRole.SALON && ownerId === user.id) { + return; + } + throw new ForbiddenException(); + } +}