diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index f1bb0d5..e577383 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { SalonCustomer } from '../customers/entities/salon-customer.entity'; import { Reserve } from '../reserves/entities/reserve.entity'; import { AuthorizationService } from './authorization.service'; import { Permission } from './entities/permission.entity'; @@ -13,7 +14,14 @@ import { StylistPolicy } from './policies/stylist.policy'; import { SubscriptionPolicy } from './policies/subscription.policy'; @Module({ - imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])], + imports: [ + TypeOrmModule.forFeature([ + Permission, + UserPermission, + Reserve, + SalonCustomer, + ]), + ], providers: [ AuthorizationService, SalonPolicy, diff --git a/src/authorization/policies/customer.policy.ts b/src/authorization/policies/customer.policy.ts index a316bbe..8874266 100644 --- a/src/authorization/policies/customer.policy.ts +++ b/src/authorization/policies/customer.policy.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AuthUser } from '../../auth/interfaces/auth-user.interface'; import { Customer } from '../../customers/entities/customer.entity'; +import { SalonCustomer } from '../../customers/entities/salon-customer.entity'; import { Reserve } from '../../reserves/entities/reserve.entity'; import { UserRole } from '../../users/enums/user-role.enum'; import { AuthorizationService } from '../authorization.service'; @@ -13,6 +14,8 @@ export class CustomerPolicy { private readonly authorizationService: AuthorizationService, @InjectRepository(Reserve) private readonly reservesRepository: Repository, + @InjectRepository(SalonCustomer) + private readonly salonCustomersRepository: Repository, ) {} async canRead(user: AuthUser, customer: Customer): Promise { @@ -23,7 +26,10 @@ export class CustomerPolicy { return customer.userId === user.id; } if (user.role === UserRole.SALON) { - return this.hasReserveAtSalon(customer.id, user.id); + return ( + (await this.hasReserveAtSalon(customer.id, user.id)) || + (await this.hasLinkAtSalon(customer.id, user.id)) + ); } return false; } @@ -49,4 +55,16 @@ export class CustomerPolicy { }, }); } + + private async hasLinkAtSalon( + customerId: string, + salonOwnerId: string, + ): Promise { + return this.salonCustomersRepository.exists({ + where: { + customerId, + salon: { ownerId: salonOwnerId }, + }, + }); + } } diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts index 27cd523..36ce0db 100644 --- a/src/customers/customers.controller.ts +++ b/src/customers/customers.controller.ts @@ -6,6 +6,8 @@ import { Param, ParseUUIDPipe, Patch, + Post, + Query, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; @@ -19,6 +21,14 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { UserRole } from '../users/enums/user-role.enum'; import { CustomersService } from './customers.service'; import { UpdateCustomerDto } from './dto/update-customer.dto'; +import { + CreateSalonCustomerDto, + FindSalonCustomersQueryDto, +} from './dto/salon-customer.dto'; +import { + SalonCustomerDetailDto, + SalonCustomerListResponseDto, +} from './dto/salon-customer-response.dto'; import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; import { ApiStandardController, @@ -35,6 +45,39 @@ import { Customer } from './entities/customer.entity'; export class CustomersController { constructor(private readonly customersService: CustomersService) {} + @Roles(UserRole.SALON) + @Permissions(PermissionCode.CUSTOMERS_READ) + @ApiStandardOkResponse(SalonCustomerListResponseDto) + @Get('salon') + findAllForSalon( + @CurrentUser() user: AuthUser, + @Query() query: FindSalonCustomersQueryDto, + ) { + return this.customersService.findAllForSalon(user, query); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.CUSTOMERS_WRITE) + @ApiStandardOkResponse(SalonCustomerDetailDto) + @Post('salon') + createForSalon( + @Body() createSalonCustomerDto: CreateSalonCustomerDto, + @CurrentUser() user: AuthUser, + ) { + return this.customersService.createForSalon(createSalonCustomerDto, user); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.CUSTOMERS_READ) + @ApiStandardOkResponse(SalonCustomerDetailDto) + @Get('salon/:id') + findOneForSalon( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.customersService.findOneForSalon(id, user); + } + @Roles(UserRole.ADMIN) @Permissions(PermissionCode.CUSTOMERS_READ) @ApiStandardOkResponse(Customer, { isArray: true }) diff --git a/src/customers/customers.module.ts b/src/customers/customers.module.ts index d39cc44..5b00340 100644 --- a/src/customers/customers.module.ts +++ b/src/customers/customers.module.ts @@ -2,14 +2,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { Salon } from '../salons/entities/salon.entity'; import { UsersModule } from '../users/users.module'; import { CustomersController } from './customers.controller'; import { CustomersService } from './customers.service'; import { Customer } from './entities/customer.entity'; +import { SalonCustomer } from './entities/salon-customer.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Customer]), + TypeOrmModule.forFeature([Customer, SalonCustomer, Salon, Reserve]), UsersModule, AuthorizationModule, AuthGuardsModule, diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 0ac145b..58244b8 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -7,17 +7,42 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CustomerPolicy } from '../authorization/policies/customer.policy'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; +import { Salon } from '../salons/entities/salon.entity'; import { UserRole } from '../users/enums/user-role.enum'; import { UsersService } from '../users/users.service'; import { CreateCustomerDto } from './dto/create-customer.dto'; +import { CreateSalonCustomerDto } from './dto/salon-customer.dto'; +import { FindSalonCustomersQueryDto } from './dto/salon-customer.dto'; +import { + SalonCustomerDetailDto, + SalonCustomerListResponseDto, + SalonCustomerSummaryDto, +} from './dto/salon-customer-response.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto'; import { Customer } from './entities/customer.entity'; +import { SalonCustomer } from './entities/salon-customer.entity'; +import { + daysBetween, + groupVisitsByCustomer, + mapSegmentToClientStatus, + parseDate, + resolveSegment, + sumTotalPurchase, +} from './helpers/customer-segmentation.helper'; @Injectable() export class CustomersService { constructor( @InjectRepository(Customer) private readonly customersRepository: Repository, + @InjectRepository(SalonCustomer) + private readonly salonCustomersRepository: Repository, + @InjectRepository(Salon) + private readonly salonsRepository: Repository, + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, private readonly usersService: UsersService, private readonly customerPolicy: CustomerPolicy, ) {} @@ -44,6 +69,91 @@ export class CustomersService { return this.customersRepository.find({ relations: { user: true } }); } + async findAllForSalon( + user: AuthUser, + query: FindSalonCustomersQueryDto, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const summaries = await this.buildSalonCustomerSummaries(salon.id); + const filtered = this.filterSalonCustomers(summaries, query); + + return { + customers: filtered, + stats: this.buildSalonCustomerStats(summaries), + }; + } + + async findOneForSalon( + id: string, + user: AuthUser, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const customer = await this.findOne(id); + + if (!(await this.customerPolicy.canRead(user, customer))) { + throw new ForbiddenException(); + } + + const isLinked = await this.salonCustomersRepository.exists({ + where: { salonId: salon.id, customerId: customer.id }, + }); + + if (!isLinked) { + throw new NotFoundException(`Customer #${id} not found in this salon`); + } + + return this.buildSalonCustomerDetail(customer, salon.id); + } + + async createForSalon( + dto: CreateSalonCustomerDto, + user: AuthUser, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const customer = await this.findOrCreateByMobile(dto); + await this.linkToSalon(salon.id, customer.id); + return this.buildSalonCustomerDetail(customer, salon.id); + } + + async linkToSalon(salonId: string, customerId: string): Promise { + await this.salonCustomersRepository.upsert( + { salonId, customerId }, + { conflictPaths: ['salonId', 'customerId'] }, + ); + } + + async findOrCreateByMobile(dto: CreateSalonCustomerDto): Promise { + const existingUser = await this.usersService.findByMobile(dto.mobile); + + if (existingUser) { + let customer = await this.customersRepository.findOne({ + where: { userId: existingUser.id }, + relations: { user: true }, + }); + + if (!customer) { + customer = await this.customersRepository.save( + this.customersRepository.create({ + userId: existingUser.id, + dateOfBirth: dto.dateOfBirth ?? '2000-01-01', + address: dto.address ?? '-', + }), + ); + customer = await this.findOne(customer.id); + } + + return customer; + } + + return this.create({ + firstName: dto.firstName, + lastName: dto.lastName, + mobile: dto.mobile, + dateOfBirth: dto.dateOfBirth ?? '2000-01-01', + address: dto.address ?? '-', + }); + } + async findOne(id: string, user?: AuthUser): Promise { const customer = await this.customersRepository.findOne({ where: { id }, @@ -100,4 +210,156 @@ export class CustomersService { const customer = await this.findOne(id); await this.usersService.remove(customer.userId); } + + private async resolveSalonForUser(user: AuthUser): Promise { + if (user.role !== UserRole.SALON) { + throw new ForbiddenException(); + } + + const salon = await this.salonsRepository.findOne({ + where: { ownerId: user.id }, + order: { createdAt: 'ASC' }, + }); + + if (!salon) { + throw new NotFoundException('Salon not found for this user'); + } + + return salon; + } + + private async buildSalonCustomerSummaries( + salonId: string, + ): Promise { + const links = await this.salonCustomersRepository.find({ + where: { salonId }, + relations: { customer: { user: true } }, + order: { createdAt: 'ASC' }, + }); + + const completedReserves = await this.reservesRepository.find({ + where: { salonId, status: ReserveStatus.COMPLETED }, + relations: { items: { serviceSkill: true, service: true } }, + order: { appointmentDate: 'ASC' }, + }); + + const visitsByCustomer = groupVisitsByCustomer(completedReserves); + const now = new Date(); + + return links.map((link) => + this.buildSummaryFromCustomer( + link.customer, + link.createdAt, + visitsByCustomer[link.customer.id] ?? [], + now, + ), + ); + } + + private async buildSalonCustomerDetail( + customer: Customer, + salonId: string, + ): Promise { + const link = await this.salonCustomersRepository.findOne({ + where: { salonId, customerId: customer.id }, + }); + + const completedReserves = await this.reservesRepository.find({ + where: { + salonId, + customerId: customer.id, + status: ReserveStatus.COMPLETED, + }, + relations: { items: { serviceSkill: true, service: true } }, + order: { appointmentDate: 'DESC' }, + }); + + const visits = groupVisitsByCustomer(completedReserves)[customer.id] ?? []; + const summary = this.buildSummaryFromCustomer( + customer, + link?.createdAt ?? customer.createdAt, + visits, + new Date(), + ); + + const serviceHistory = completedReserves.flatMap((reserve) => + (reserve.items ?? []).map((item) => ({ + id: item.id, + serviceName: item.service?.title ?? 'خدمت', + serviceIcon: item.service?.iconUrl ?? null, + date: reserve.appointmentDate, + price: reserve.totalAmount, + })), + ); + + return { + ...summary, + serviceHistory, + }; + } + + private buildSummaryFromCustomer( + customer: Customer, + membershipDate: Date, + visits: ReturnType[string], + referenceDate: Date, + ): SalonCustomerSummaryDto { + const totalPurchase = sumTotalPurchase(visits); + const visitCount = visits.length; + const lastVisit = visits[visits.length - 1]; + const lastVisitDate = lastVisit?.appointmentDate ?? null; + const daysSinceVisit = lastVisitDate + ? daysBetween(parseDate(lastVisitDate), referenceDate) + : null; + const segment = lastVisit + ? resolveSegment(daysSinceVisit ?? 0, lastVisit.renewalDays) + : 'active'; + + return { + id: customer.id, + firstName: customer.user.firstName, + lastName: customer.user.lastName, + mobile: customer.user.mobile, + dateOfBirth: customer.dateOfBirth, + address: customer.address, + membershipDate: membershipDate.toISOString(), + totalPurchase, + visitCount, + lastVisitDate, + daysSinceVisit, + status: mapSegmentToClientStatus(segment, totalPurchase), + }; + } + + private filterSalonCustomers( + customers: SalonCustomerSummaryDto[], + query: FindSalonCustomersQueryDto, + ): SalonCustomerSummaryDto[] { + const search = query.search?.trim().toLowerCase(); + + return customers.filter((customer) => { + const fullName = + `${customer.firstName} ${customer.lastName}`.toLowerCase(); + const matchesSearch = + !search || + fullName.includes(search) || + customer.mobile.includes(search); + const matchesStatus = + !query.status || customer.status === query.status; + + return matchesSearch && matchesStatus; + }); + } + + private buildSalonCustomerStats(customers: SalonCustomerSummaryDto[]) { + return { + total: customers.length, + active: customers.filter((customer) => customer.status === 'active') + .length, + atRisk: customers.filter((customer) => customer.status === 'at-risk') + .length, + lost: customers.filter((customer) => customer.status === 'lost').length, + vip: customers.filter((customer) => customer.status === 'vip').length, + }; + } } diff --git a/src/customers/dto/salon-customer-response.dto.ts b/src/customers/dto/salon-customer-response.dto.ts new file mode 100644 index 0000000..50563e7 --- /dev/null +++ b/src/customers/dto/salon-customer-response.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SalonCustomerStatsDto { + @ApiProperty({ example: 42 }) + total: number; + + @ApiProperty({ example: 28 }) + active: number; + + @ApiProperty({ example: 8 }) + atRisk: number; + + @ApiProperty({ example: 4 }) + lost: number; + + @ApiProperty({ example: 6 }) + vip: number; +} + +export class SalonCustomerServiceHistoryDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ example: 'کوتاهی مو' }) + serviceName: string; + + @ApiPropertyOptional({ example: 'https://example.com/icon.png' }) + serviceIcon: string | null; + + @ApiProperty({ example: '2025-06-12' }) + date: string; + + @ApiProperty({ example: 850_000 }) + price: number; +} + +export class SalonCustomerSummaryDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ example: 'سارا' }) + firstName: string; + + @ApiProperty({ example: 'احمدی' }) + lastName: string; + + @ApiProperty({ example: '09123456789' }) + mobile: string; + + @ApiProperty({ example: '1995-03-15' }) + dateOfBirth: string; + + @ApiProperty({ example: 'تهران، ونک' }) + address: string; + + @ApiProperty({ example: '2024-01-10T10:00:00.000Z' }) + membershipDate: string; + + @ApiProperty({ example: 12_500_000 }) + totalPurchase: number; + + @ApiProperty({ example: 8 }) + visitCount: number; + + @ApiPropertyOptional({ example: '2025-06-12' }) + lastVisitDate: string | null; + + @ApiPropertyOptional({ example: 12 }) + daysSinceVisit: number | null; + + @ApiProperty({ enum: ['active', 'at-risk', 'lost', 'vip'] }) + status: 'active' | 'at-risk' | 'lost' | 'vip'; +} + +export class SalonCustomerListResponseDto { + @ApiProperty({ type: [SalonCustomerSummaryDto] }) + customers: SalonCustomerSummaryDto[]; + + @ApiProperty({ type: SalonCustomerStatsDto }) + stats: SalonCustomerStatsDto; +} + +export class SalonCustomerDetailDto extends SalonCustomerSummaryDto { + @ApiProperty({ type: [SalonCustomerServiceHistoryDto] }) + serviceHistory: SalonCustomerServiceHistoryDto[]; +} diff --git a/src/customers/dto/salon-customer.dto.ts b/src/customers/dto/salon-customer.dto.ts new file mode 100644 index 0000000..2809d29 --- /dev/null +++ b/src/customers/dto/salon-customer.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Length, + Matches, +} from 'class-validator'; + +export class CreateSalonCustomerDto { + @ApiProperty({ example: 'سارا' }) + @IsString() + @Length(1, 100) + @IsNotEmpty() + firstName: string; + + @ApiProperty({ example: 'احمدی' }) + @IsString() + @Length(1, 100) + @IsNotEmpty() + lastName: string; + + @ApiProperty({ example: '09123456789' }) + @Matches(/^09\d{9}$/) + mobile: string; + + @ApiPropertyOptional({ example: '1995-03-15' }) + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @ApiPropertyOptional({ example: 'تهران، ونک' }) + @IsOptional() + @IsString() + @Length(1, 500) + address?: string; +} + +export class FindSalonCustomersQueryDto { + @ApiPropertyOptional({ description: 'Search by name or mobile' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ + enum: ['active', 'at-risk', 'lost', 'vip'], + }) + @IsOptional() + @IsIn(['active', 'at-risk', 'lost', 'vip']) + status?: 'active' | 'at-risk' | 'lost' | 'vip'; +} diff --git a/src/customers/entities/salon-customer.entity.ts b/src/customers/entities/salon-customer.entity.ts new file mode 100644 index 0000000..02f0e95 --- /dev/null +++ b/src/customers/entities/salon-customer.entity.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from '../../salons/entities/salon.entity'; +import { Customer } from './customer.entity'; + +@Entity('salon_customers') +@Unique(['salonId', 'customerId']) +@Index(['salonId']) +@Index(['customerId']) +export class SalonCustomer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column() + customerId: string; + + @ManyToOne(() => Customer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customerId' }) + customer: Customer; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/customers/helpers/customer-segmentation.helper.ts b/src/customers/helpers/customer-segmentation.helper.ts new file mode 100644 index 0000000..4d2ba31 --- /dev/null +++ b/src/customers/helpers/customer-segmentation.helper.ts @@ -0,0 +1,112 @@ +import { ReserveItem } from '../../reserves/entities/reserve-item.entity'; +import { Reserve } from '../../reserves/entities/reserve.entity'; + +export const DEFAULT_RENEWAL_DAYS = 30; +export const AT_RISK_GRACE_DAYS = 15; +export const RETURNABLE_GRACE_DAYS = 30; +export const VIP_PURCHASE_THRESHOLD = 5_000_000; + +export type CustomerSegment = 'active' | 'at-risk' | 'returnable' | 'lost'; + +export interface CustomerVisit { + appointmentDate: string; + renewalDays: number; + totalAmount: number; +} + +export function groupVisitsByCustomer( + reserves: Reserve[], +): Record { + const grouped: Record = {}; + + for (const reserve of reserves) { + const renewalDays = resolveRenewalDays(reserve.items); + const visit: CustomerVisit = { + appointmentDate: reserve.appointmentDate, + renewalDays, + totalAmount: reserve.totalAmount, + }; + + if (!grouped[reserve.customerId]) { + grouped[reserve.customerId] = []; + } + grouped[reserve.customerId].push(visit); + } + + return grouped; +} + +export function resolveRenewalDays(items: ReserveItem[] | undefined): number { + if (!items?.length) { + return DEFAULT_RENEWAL_DAYS; + } + + const renewalDays = items + .map((item) => item.serviceSkill?.renewalDays ?? DEFAULT_RENEWAL_DAYS) + .filter((value) => value > 0); + + if (renewalDays.length === 0) { + return DEFAULT_RENEWAL_DAYS; + } + + return Math.max(...renewalDays); +} + +export function resolveSegment( + daysSince: number, + renewalDays: number, +): CustomerSegment { + if (daysSince <= renewalDays) { + return 'active'; + } + + const atRiskLimit = renewalDays + AT_RISK_GRACE_DAYS; + if (daysSince <= atRiskLimit) { + return 'at-risk'; + } + + const returnableLimit = atRiskLimit + RETURNABLE_GRACE_DAYS; + if (daysSince <= returnableLimit) { + return 'returnable'; + } + + return 'lost'; +} + +export function daysBetween(from: Date, to: Date): number { + const start = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const end = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.floor((end - start) / (1000 * 60 * 60 * 24)); +} + +export function parseDate(value: string): Date { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); +} + +export function sumTotalPurchase(visits: CustomerVisit[]): number { + return visits.reduce((sum, visit) => sum + visit.totalAmount, 0); +} + +export function isVipCustomer(totalPurchase: number): boolean { + return totalPurchase >= VIP_PURCHASE_THRESHOLD; +} + +export function mapSegmentToClientStatus( + segment: CustomerSegment, + totalPurchase: number, +): 'active' | 'at-risk' | 'lost' | 'vip' { + if (isVipCustomer(totalPurchase)) { + return 'vip'; + } + + if (segment === 'returnable' || segment === 'lost') { + return 'lost'; + } + + if (segment === 'at-risk') { + return 'at-risk'; + } + + return 'active'; +} diff --git a/src/database/migrations/1721100000000-AddSalonCustomers.ts b/src/database/migrations/1721100000000-AddSalonCustomers.ts new file mode 100644 index 0000000..dd17724 --- /dev/null +++ b/src/database/migrations/1721100000000-AddSalonCustomers.ts @@ -0,0 +1,89 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, + TableUnique, +} from 'typeorm'; + +export class AddSalonCustomers1721100000000 implements MigrationInterface { + name = 'AddSalonCustomers1721100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'salon_customers', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'customerId', type: 'uuid' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'salon_customers', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'salon_customers', + new TableForeignKey({ + columnNames: ['customerId'], + referencedTableName: 'customers', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'salon_customers', + new TableIndex({ + name: 'IDX_salon_customers_salonId', + columnNames: ['salonId'], + }), + ); + + await queryRunner.createIndex( + 'salon_customers', + new TableIndex({ + name: 'IDX_salon_customers_customerId', + columnNames: ['customerId'], + }), + ); + + await queryRunner.createUniqueConstraint( + 'salon_customers', + new TableUnique({ + name: 'UQ_salon_customers_salonId_customerId', + columnNames: ['salonId', 'customerId'], + }), + ); + + await queryRunner.query(` + INSERT INTO salon_customers ("salonId", "customerId", "createdAt", "updatedAt") + SELECT DISTINCT "salonId", "customerId", NOW(), NOW() + FROM reserves + ON CONFLICT ("salonId", "customerId") DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('salon_customers'); + } +} diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts index 27928ef..2366633 100644 --- a/src/reserves/reserves.service.ts +++ b/src/reserves/reserves.service.ts @@ -89,6 +89,7 @@ export class ReservesService { }); const saved = await this.reservesRepository.save(reserve); + await this.customersService.linkToSalon(saved.salonId, saved.customerId); await this.logStatusChange(saved.id, null, saved.status, user.id); return this.findOne(saved.id, user); }