sent messages

This commit is contained in:
2026-06-30 11:12:27 +03:30
parent 3420de0011
commit 442de8582f
10 changed files with 481 additions and 0 deletions
+2
View File
@@ -8,6 +8,7 @@ import { getTypeOrmOptions } from './database/typeorm-options';
import { ReservesModule } from './reserves/reserves.module'; import { ReservesModule } from './reserves/reserves.module';
import { SalonsModule } from './salons/salons.module'; import { SalonsModule } from './salons/salons.module';
import { ServicesModule } from './services/services.module'; import { ServicesModule } from './services/services.module';
import { SentMessagesModule } from './sent-messages/sent-messages.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { SuggestionsModule } from './suggestions/suggestions.module'; import { SuggestionsModule } from './suggestions/suggestions.module';
import { StylistsModule } from './stylists/stylists.module'; import { StylistsModule } from './stylists/stylists.module';
@@ -29,6 +30,7 @@ import { UsersModule } from './users/users.module';
ReservesModule, ReservesModule,
SuggestionsModule, SuggestionsModule,
SmsTemplatesModule, SmsTemplatesModule,
SentMessagesModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
@@ -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<void> {
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<void> {
await queryRunner.dropTable('sent_messages', true);
await queryRunner.query(`DROP TYPE "sent_messages_status_enum"`);
}
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { SentMessageStatus } from '../enums/sent-message-status.enum';
export class UpdateSentMessageDto {
@IsEnum(SentMessageStatus)
status: SentMessageStatus;
}
@@ -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;
}
@@ -0,0 +1,5 @@
export enum SentMessageStatus {
PENDING = 'pending',
SENT = 'sent',
FAILED = 'failed',
}
@@ -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);
}
}
+25
View File
@@ -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 {}
+143
View File
@@ -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<SentMessage>,
private readonly salonsService: SalonsService,
private readonly customersService: CustomersService,
private readonly reservesService: ReservesService,
private readonly authorizationService: AuthorizationService,
) {}
async create(
createDto: CreateSentMessageDto,
user: AuthUser,
): Promise<SentMessage> {
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<SentMessage[]> {
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<SentMessage> {
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<SentMessage> {
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();
}
}