diff --git a/.gitignore b/.gitignore index eb85ecc..c583a97 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +.github +AGENTS.md +.skillsrc diff --git a/TASKS.md b/TASKS.md index 042d56d..b439c59 100644 --- a/TASKS.md +++ b/TASKS.md @@ -131,7 +131,7 @@ Stack: NestJS 11 + TypeORM + MySQL 8. yarn. Each task atomic, stop for review be ## Task 10 — Availability query -`[x]` _(depends on: Task 9)_ +`[✓]` _(depends on: Task 9)_ - `src/appointment/dto/get-availability.dto.ts` — `{ serviceTypeId: string, date: string }` (query params) - `AppointmentService.getAvailability(dealershipId, serviceTypeId, date)`: @@ -147,14 +147,14 @@ Stack: NestJS 11 + TypeORM + MySQL 8. yarn. Each task atomic, stop for review be ## Task 11 — Booking transaction -`[ ]` _(depends on: Task 10)_ +`[[✓]` _(depends on: Task 10)_ - `src/appointment/dto/create-appointment.dto.ts` — `{ dealershipId, vehicleId, serviceTypeId, startAt }`; `@IsISO8601()` on `startAt` -- `AppointmentService.createAppointment(dto)`: transaction handle by typeorm-transaction +- `AppointmentService.createAppointment(dto)`: create custom repository, query by typeorm QueryBuilder 1. Validate `startAt` aligned to fixed 15-min absolute grid and within dealership hours → 422 if not 2. Compute slots from validated `startAt` 3. Verify ≥1 qualified tech at dealership → 422 if none - 4. Begin transaction + 4. Begin transaction with typeorm-transaction 5. Candidate loop (**hotspot-safe assignment**): - Build top-K qualified active technicians by load, randomize order - Build top-K active free bays by load, randomize order @@ -171,18 +171,16 @@ Stack: NestJS 11 + TypeORM + MySQL 8. yarn. Each task atomic, stop for review be --- -## Task 12 — Cancel, reschedule, fetch +## Task 12 — Cancel, fetch -`[ ]` _(depends on: Task 11)_ +`[✓]` _(depends on: Task 11)_ -- `src/appointment/dto/patch-appointment.dto.ts` — `{ action: 'cancel' | 'reschedule', startAt?: string }` - `AppointmentService.cancelAppointment(id)` — txn: `status = CANCELLED`, delete reservations -- `AppointmentService.rescheduleAppointment(id, newStartAt)` — txn: delete old reservations, re-run booking attempt at new time - `AppointmentController`: - `GET /appointments/:id` — fetch with all relations - - `PATCH /appointments/:id` — route to cancel or reschedule + - `PATCH /appointments/:id` — route to cancel -**Verify:** `yarn build`; unit tests: cancel clears reservations; reschedule retry-on-dup; reschedule outside-hours → 422; GET returns relations +**Verify:** `yarn build`; unit tests: cancel clears reservations; GET returns relations --- @@ -195,6 +193,5 @@ Stack: NestJS 11 + TypeORM + MySQL 8. yarn. Each task atomic, stop for review be - Slot size: 15 min constant in `slot.util.ts` - Slot grid is absolute (anchored), independent from dealership `open_time` - No idempotency key header; duplicate request prevented by DB uniqueness + transactional reservation conflict -- PATCH body: `{ action: 'cancel' | 'reschedule', startAt?: string }` - Out of scope: notifications, payments, frontend, auth, multi-region - Swagger doc all API with CLI plugin, description and example for all property diff --git a/src/common/filters/exception.filter.ts b/src/common/filters/exception.filter.ts index 9aa921c..33b8608 100644 --- a/src/common/filters/exception.filter.ts +++ b/src/common/filters/exception.filter.ts @@ -23,9 +23,7 @@ export class CustomExceptionFilter implements ExceptionFilter { }); const statusCode = exception.getStatus(); const payload = exception.getResponse(); - response - .status(statusCode) - .json(this.formatResponse(statusCode, payload)); + response.status(statusCode).json(this.formatResponse(statusCode, payload)); return; } case exception instanceof TypeORMError: { @@ -36,9 +34,7 @@ export class CustomExceptionFilter implements ExceptionFilter { stack: sql, }); const { statusCode, message } = this.handleTypeOrmException(exception); - response - .status(statusCode) - .json(this.formatResponse(statusCode, message)); + response.status(statusCode).json(this.formatResponse(statusCode, message)); return; } default: { @@ -56,8 +52,7 @@ export class CustomExceptionFilter implements ExceptionFilter { } private handleTypeOrmException(exception = {} as TypeORMError) { - const { driverError: { code, errno } = {} } = - exception as QueryFailedError; + const { driverError: { code, errno } = {} } = exception as QueryFailedError; switch (errno ?? code) { case 404: return { @@ -86,7 +81,7 @@ export class CustomExceptionFilter implements ExceptionFilter { case 1062: return { statusCode: HttpStatus.CONFLICT, - message: 'Temporary conflict. Please retry.', + message: 'This data already exists.', }; case 'ER_NO_DEFAULT_FOR_FIELD': case 1364: @@ -115,14 +110,10 @@ export class CustomExceptionFilter implements ExceptionFilter { const { message, error } = payload; const resMsg = - typeof message === 'string' || Array.isArray(message) - ? message - : 'Request failed'; + typeof message === 'string' || Array.isArray(message) ? message : 'Request failed'; const resErrorMsg = - typeof error === 'string' - ? error - : (HttpStatus[statusCode] ?? 'HttpException'); + typeof error === 'string' ? error : (HttpStatus[statusCode] ?? 'HttpException'); return { statusCode, diff --git a/src/configs/app.config.ts b/src/configs/app.config.ts index 52d38c6..8e8abc8 100644 --- a/src/configs/app.config.ts +++ b/src/configs/app.config.ts @@ -12,6 +12,7 @@ export default () => ({ keepConnectionAlive: true, extra: { connectionLimit: 10 }, timezone: 'Z', + logging: true, replication: { defaultMode: 'master', restoreNodeTimeout: 3000, @@ -39,5 +40,9 @@ export default () => ({ description: 'Appointment Scheduler Service API documentation', version: '1.0', path: 'api-docs', + swaggerOptions: { + tagsSorter: 'alpha', + operationsSorter: 'method', + }, }, }); diff --git a/src/configs/config.interface.ts b/src/configs/config.interface.ts index 3fee87b..d562a02 100644 --- a/src/configs/config.interface.ts +++ b/src/configs/config.interface.ts @@ -1,3 +1,4 @@ +import { SwaggerCustomOptions } from '@nestjs/swagger'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; export interface SwaggerConfig { @@ -6,6 +7,7 @@ export interface SwaggerConfig { description: string; version: string; path: string; + swaggerOptions: SwaggerCustomOptions; } export interface RuntimeConfig { diff --git a/src/database/database.module.ts b/src/database/database.module.ts index d15f388..015a4c8 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -9,8 +9,7 @@ import { RuntimeConfig } from '@src/configs/config.interface'; imports: [ TypeOrmModule.forRootAsync({ useFactory: (configService: ConfigService) => { - const database = - configService.getOrThrow('database'); + const database = configService.getOrThrow('database'); return database; }, dataSourceFactory: async (options) => { diff --git a/src/database/entities/base.entity.ts b/src/database/entities/base.entity.ts index 46f10b2..c35121e 100644 --- a/src/database/entities/base.entity.ts +++ b/src/database/entities/base.entity.ts @@ -1,7 +1,11 @@ -import { Timestamp } from '@src/database/entities/timestamp.entity'; -import { PrimaryGeneratedColumn } from 'typeorm'; +import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; -export abstract class BaseEntity extends Timestamp { +export abstract class BaseEntity { @PrimaryGeneratedColumn({ type: 'int', unsigned: true }) id: number; + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; } diff --git a/src/database/entities/timestamp.entity.ts b/src/database/entities/timestamp.entity.ts deleted file mode 100644 index e812a8a..0000000 --- a/src/database/entities/timestamp.entity.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -export abstract class Timestamp { - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) - updatedAt: Date; -} diff --git a/src/main.ts b/src/main.ts index 91fd552..baef23c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,7 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; -import { - initializeTransactionalContext, - StorageDriver, -} from 'typeorm-transactional'; +import { initializeTransactionalContext, StorageDriver } from 'typeorm-transactional'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -13,7 +11,7 @@ import helmet from 'helmet'; async function bootstrap() { initializeTransactionalContext({ storageDriver: StorageDriver.AUTO }); - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(AppModule, { logger: loggerConfig, }); @@ -39,14 +37,15 @@ async function bootstrap() { const configService = app.get(ConfigService); const swaggerConfig = configService.getOrThrow('swagger'); - if (swaggerConfig.enabled) { + const { enabled, title, description, version, path, swaggerOptions } = swaggerConfig; + if (enabled) { const swagger = new DocumentBuilder() - .setTitle(swaggerConfig.title) - .setDescription(swaggerConfig.description) - .setVersion(swaggerConfig.version) + .setTitle(title) + .setDescription(description) + .setVersion(version) .build(); const documentFactory = () => SwaggerModule.createDocument(app, swagger); - SwaggerModule.setup(swaggerConfig.path, app, documentFactory); + SwaggerModule.setup(path, app, documentFactory, { swaggerOptions }); } const port = configService.getOrThrow('port'); diff --git a/src/modules/appointment/appointment.controller.ts b/src/modules/appointment/appointment.controller.ts index 21491c9..c933a28 100644 --- a/src/modules/appointment/appointment.controller.ts +++ b/src/modules/appointment/appointment.controller.ts @@ -1,16 +1,40 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { AppointmentService } from './appointment.service'; +import { CreateAppointmentDto } from './dtos/create-appointment.dto'; import { GetAvailabilityDto } from './dtos/get-availability.dto'; +import { SearchAppointmentDto } from './dtos/search-appointment.dto'; +import { Appointment } from './entities/appointment.entity'; +import { AppointmentSearchResult } from './interfaces/appointment-search.type'; -@Controller('dealerships') +@Controller('appointments') export class AppointmentController { constructor(private readonly appointmentService: AppointmentService) {} - @Get(':id/availability') + @Get() + search(@Query() query: SearchAppointmentDto): Promise { + return this.appointmentService.search(query); + } + + @Get('availability/:id') getAvailability( @Param('id') dealershipId: number, @Query() { serviceTypeId, date }: GetAvailabilityDto, ): Promise { return this.appointmentService.getAvailability(dealershipId, serviceTypeId, date); } + + @Post() + create(@Body() dto: CreateAppointmentDto): Promise { + return this.appointmentService.createAppointment(dto); + } + + @Get(':id') + findOne(@Param('id') id: number): Promise { + return this.appointmentService.findOne(id); + } + + @Patch(':id') + patch(@Param('id') id: number): Promise { + return this.appointmentService.cancelAppointment(id); + } } diff --git a/src/modules/appointment/appointment.helper.ts b/src/modules/appointment/appointment.helper.ts index cab4eca..133ce59 100644 --- a/src/modules/appointment/appointment.helper.ts +++ b/src/modules/appointment/appointment.helper.ts @@ -1,13 +1,18 @@ import { toUtc } from '@src/shared/utils/date.helper'; import { Dealership } from '../dealership/entities/dealership.entity'; -import { EResourceType, GRID_ANCHOR_UTC, SLOT_SIZE_MINUTES } from './contants/appointment.contanst'; +import { + EResourceType, + GRID_ANCHOR_UTC, + SLOT_SIZE_MINUTES, + TOP_K_RESOURCES, +} from './constants/appointment.constant'; import { Reservation } from './interfaces/resource-reservation.type'; import { validateDuration } from '../service-type/service-type.helper'; +import { shuffle } from '@src/shared/utils/common.helper'; +import { ResourceReservation } from './entities/resource-reservation.entity'; export function isGridAligned(startAt: Date, slotSize = SLOT_SIZE_MINUTES): boolean { - const slotMs = slotSize * 60 * 1000; - const diffMs = startAt.getTime() - GRID_ANCHOR_UTC; - return diffMs % slotMs === 0; + return (toUtc(startAt).getTime() - GRID_ANCHOR_UTC) % (slotSize * 60 * 1000) === 0; } export function mergeSlots( @@ -27,11 +32,12 @@ export function getReserveKey({ resourceType, resourceId, slotStart }: Reservati } /** - * @description Builds an array of all candidate slot times within the dealership's operating hours. + * @description Builds an array of candidate slot times within the dealership's operating hours. */ export function getDealershipSlots( dealershipTime: Pick, date: string, + durationMinutes: number, slotSizeMins = SLOT_SIZE_MINUTES, ): Date[] { const { openTime, closeTime, timezone } = dealershipTime; @@ -39,16 +45,26 @@ export function getDealershipSlots( const closeTimeUtc = toUtc(date, closeTime, timezone).getTime(); const slotSizeMs = slotSizeMins * 60 * 1000; + const durationMs = durationMinutes * 60 * 1000; const firstSlot = GRID_ANCHOR_UTC + Math.ceil((openTimeUtc - GRID_ANCHOR_UTC) / slotSizeMs) * slotSizeMs; const slots: Date[] = []; - for (let start = firstSlot; start + slotSizeMs <= closeTimeUtc; start += slotSizeMs) + for (let start = firstSlot; start + durationMs <= closeTimeUtc; start += slotSizeMs) slots.push(new Date(start)); return slots; } - +const isResourceFree = ( + slots: Date[], + blocked: Set, + type: EResourceType, + resourceId: number, +): boolean => { + return slots.every( + (slotStart) => !blocked.has(getReserveKey({ resourceType: type, resourceId, slotStart })), + ); +}; export const hasFreeResource = ( blocked: Set, @@ -58,13 +74,44 @@ export const hasFreeResource = (startAt: Date): boolean => { const slots = mergeSlots(startAt, durationMinutes); return Object.entries(activeResources).every(([type, ids]) => - ids.some((resourceId) => - slots.every( - (slotStart) => - !blocked.has( - getReserveKey({ resourceType: type as EResourceType, resourceId, slotStart }), - ), - ), - ), + ids.some(isResourceFree.bind(null, slots, blocked, type)), ); }; + +export const getFreeResources = ( + activeResources: Record, + slots: Date[], + blocked: Set, +) => { + const result = {} as Record; + Object.entries(activeResources).forEach(([type, ids]) => { + result[type as EResourceType] = ids.filter(isResourceFree.bind(null, slots, blocked, type)); + }); + return result; +}; + +export function pickByLowestLoad( + candidates: Record, + reservations: ResourceReservation[], + topK = TOP_K_RESOURCES, +) { + const counts = {} as Record>; + Object.entries(candidates).forEach( + ([type, ids]) => (counts[type] = Object.fromEntries(ids.map((id) => [id, 0]))), + ); + + for (const { resourceType, resourceId } of reservations) + if (counts[resourceType]?.[resourceId] !== undefined) counts[resourceType][resourceId] += 1; + + const lowestLoad = {} as Record; + const remaining = {} as Record; + for (const type of Object.values(EResourceType)) { + const topKIds = Object.entries(counts[type] ?? {}) + .sort(([, a], [, b]) => a - b) + .slice(0, topK) + .map(([key]) => Number(key)); + lowestLoad[type] = shuffle(topKIds); + remaining[type] = shuffle(candidates[type].filter((id) => !lowestLoad[type].includes(id))); + } + return { lowestLoad, remaining }; +} diff --git a/src/modules/appointment/appointment.module.ts b/src/modules/appointment/appointment.module.ts index 9538d1e..d51d449 100644 --- a/src/modules/appointment/appointment.module.ts +++ b/src/modules/appointment/appointment.module.ts @@ -8,6 +8,8 @@ import { AppointmentController } from './appointment.controller'; import { AppointmentService } from './appointment.service'; import { Appointment } from './entities/appointment.entity'; import { ResourceReservation } from './entities/resource-reservation.entity'; +import { VehicleModule } from '../vehicle/vehicle.module'; +import { AppointmentRepository } from './appointment.repository'; @Module({ imports: [ @@ -16,8 +18,9 @@ import { ResourceReservation } from './entities/resource-reservation.entity'; ServiceTypeModule, ServiceBayModule, TechnicianModule, + VehicleModule, ], - providers: [AppointmentService], + providers: [AppointmentService, AppointmentRepository], controllers: [AppointmentController], exports: [AppointmentService], }) diff --git a/src/modules/appointment/appointment.repository.ts b/src/modules/appointment/appointment.repository.ts new file mode 100644 index 0000000..24bfc4e --- /dev/null +++ b/src/modules/appointment/appointment.repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ServiceBay } from '../service-bay/entities/service-bay.entity'; +import { Technician } from '../technician/entities/technician.entity'; +import { EResourceType } from './constants/appointment.constant'; +import { Appointment } from './entities/appointment.entity'; +import { ResourceReservation } from './entities/resource-reservation.entity'; + +@Injectable() +export class AppointmentRepository extends Repository { + constructor(private readonly dataSource: DataSource) { + super(Appointment, dataSource.createEntityManager()); + } + + async lockResource(id: number, type: EResourceType): Promise { + const entity = { [EResourceType.BAY]: ServiceBay, [EResourceType.TECH]: Technician }; + return this.manager + .createQueryBuilder(entity[type], 'resource') + .where('resource.id = :id', { id }) + .setLock('pessimistic_write') + .setOnLocked('skip_locked') + .getOne(); + } + + search(params: { + dealershipId?: number; + vehicleId?: number; + skip: number; + take: number; + }): Promise<[Appointment[], number]> { + const { dealershipId, vehicleId, skip, take } = params; + const qb = this.createQueryBuilder('appointment') + .leftJoinAndSelect('appointment.dealership', 'dealership') + .leftJoinAndSelect('appointment.vehicle', 'vehicle') + .leftJoinAndSelect('appointment.technician', 'technician') + .leftJoinAndSelect('appointment.serviceBay', 'serviceBay') + .leftJoinAndSelect('appointment.serviceType', 'serviceType') + .orderBy('appointment.start_at', 'DESC') + .skip(skip) + .take(take); + + if (dealershipId) qb.andWhere('appointment.dealership_id = :dealershipId', { dealershipId }); + if (vehicleId) qb.andWhere('appointment.vehicle_id = :vehicleId', { vehicleId }); + + return qb.getManyAndCount(); + } + + async createAppointment( + appointment: Partial, + reservations: Partial[], + ): Promise { + const savedAppointment = await this.save(appointment); + + await this.manager.getRepository(ResourceReservation).insert( + reservations.map((reservation) => ({ + ...reservation, + appointmentId: savedAppointment.id, + })), + ); + + return savedAppointment; + } +} diff --git a/src/modules/appointment/appointment.service.ts b/src/modules/appointment/appointment.service.ts index 62e9d47..c60843d 100644 --- a/src/modules/appointment/appointment.service.ts +++ b/src/modules/appointment/appointment.service.ts @@ -1,19 +1,52 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import dayjs, { toLocal, toUtc } from '@src/shared/utils/date.helper'; +import { MysqlError } from 'mysql'; +import { In, Not, QueryFailedError, Repository } from 'typeorm'; +import { IsolationLevel, Propagation, Transactional } from 'typeorm-transactional'; +import { DealershipService } from '../dealership/dealership.service'; +import { Dealership } from '../dealership/entities/dealership.entity'; import { ServiceBayService } from '../service-bay/service-bay.service'; +import { ServiceTypeService } from '../service-type/service-type.service'; import { TechnicianService } from '../technician/technician.service'; -import { getDealershipSlots, getReserveKey, hasFreeResource } from './appointment.helper'; -import { EResourceType } from './contants/appointment.contanst'; +import { VehicleService } from '../vehicle/vehicle.service'; +import { + getDealershipSlots, + getFreeResources, + getReserveKey, + hasFreeResource, + isGridAligned, + mergeSlots, + pickByLowestLoad, +} from './appointment.helper'; +import { AppointmentRepository } from './appointment.repository'; +import { + DUPLICATE_VEHICLE_BOOKING_INDEX, + EAppointmentStatus, + EResourceType, +} from './constants/appointment.constant'; +import { AppointmentErrorMessages } from './constants/appointment.message'; +import { CreateAppointmentDto } from './dtos/create-appointment.dto'; +import { SearchAppointmentDto } from './dtos/search-appointment.dto'; +import { Appointment } from './entities/appointment.entity'; import { ResourceReservation } from './entities/resource-reservation.entity'; -import { toLocal } from '@src/shared/utils/date.helper'; +import { AppointmentSearchResult } from './interfaces/appointment-search.type'; @Injectable() export class AppointmentService { constructor( + private readonly appointmentRepo: AppointmentRepository, @InjectRepository(ResourceReservation) private readonly reservationRepo: Repository, + private readonly dealershipService: DealershipService, + private readonly vehicleService: VehicleService, private readonly serviceBayService: ServiceBayService, + private readonly serviceTypeService: ServiceTypeService, private readonly technicianService: TechnicianService, ) {} @@ -23,8 +56,8 @@ export class AppointmentService { date: string, ): Promise { const [technicians, bays] = await Promise.all([ - this.technicianService.findActive({ serviceTypeId, dealershipId }), - this.serviceBayService.findByDealershipId(dealershipId), + this.technicianService.findBy({ serviceTypeId, dealershipId }, true), + this.serviceBayService.findByDealership(dealershipId, true), ]); const qualifiedTechIds = technicians.map((tech) => tech.id); @@ -40,15 +73,10 @@ export class AppointmentService { ] = technicians; const { durationMinutes } = serviceType.find(({ id }) => id === serviceTypeId)!; - const slots = getDealershipSlots(dealership, date); + const slots = getDealershipSlots(dealership, date, durationMinutes); if (!slots.length) return []; - const reservations = await this.reservationRepo.find({ - where: { - resourceId: In([...qualifiedTechIds, ...activeBayIds]), - slotStart: In(slots), - }, - }); + const reservations = await this.getReservations([...qualifiedTechIds, ...activeBayIds], slots); const blocked = new Set(reservations.map(getReserveKey)); const filterFn = hasFreeResource(blocked, durationMinutes, { [EResourceType.TECH]: qualifiedTechIds, @@ -56,4 +84,199 @@ export class AppointmentService { }); return slots.filter(filterFn).map((slot) => toLocal(slot, timezone).toISOString()); } + + async search(query: SearchAppointmentDto): Promise { + const { dealershipId, vehicleId, page, limit } = query; + + const [items, total] = await this.appointmentRepo.search({ + dealershipId, + vehicleId, + skip: (page - 1) * limit, + take: limit, + }); + + return { + items, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findOne(id: number): Promise { + const appointment = await this.appointmentRepo.findOne({ + where: { id }, + relations: { + dealership: true, + vehicle: { customer: true }, + technician: true, + serviceBay: true, + serviceType: true, + reservations: true, + }, + }); + if (!appointment) throw new NotFoundException(AppointmentErrorMessages.NOT_FOUND); + return appointment; + } + + async exists(id: number): Promise { + const exists = await this.appointmentRepo.exists({ where: { id } }); + if (!exists) throw new NotFoundException(AppointmentErrorMessages.NOT_FOUND); + } + + async getReservations(resourceIds: number[], slots: Date[]): Promise { + const reservations = await this.reservationRepo.find({ + where: { + resourceId: In(resourceIds), + slotStart: In(slots), + }, + }); + return reservations; + } + + @Transactional({ isolationLevel: IsolationLevel.READ_COMMITTED }) + async createAppointment(dto: CreateAppointmentDto): Promise { + const { dealershipId, serviceTypeId, vehicleId, startAt } = dto; + + const [dealership, { durationMinutes }, { customerId }] = await Promise.all([ + this.dealershipService.findOne(dealershipId), + this.serviceTypeService.findOne(serviceTypeId), + this.vehicleService.findOne(vehicleId), + ]); + + const { startAtUtc, endAtUtc } = this.validateTime(startAt, dealership, durationMinutes); + + const isExisted = await this.appointmentRepo.exists({ + where: { vehicleId, startAt: startAtUtc, status: Not(EAppointmentStatus.CANCELLED) }, + }); + if (isExisted) throw new ConflictException(AppointmentErrorMessages.DUPLICATE_VEHICLE_BOOKING); + + const slots = mergeSlots(startAtUtc, durationMinutes); + + const [technicians, bays] = await Promise.all([ + this.technicianService.findBy({ serviceTypeId, dealershipId }, true), + this.serviceBayService.findByDealership(dealershipId, true), + ]); + + if (!technicians.length || !bays.length) + throw new NotFoundException(AppointmentErrorMessages.NO_RESOURCE); + + const techIds = technicians.map(({ id }) => id); + const bayIds = bays.map(({ id }) => id); + const reservations = await this.getReservations([...techIds, ...bayIds], slots); + const blocked = new Set(reservations.map(getReserveKey)); + + const freeResources = getFreeResources( + { [EResourceType.TECH]: techIds, [EResourceType.BAY]: bayIds }, + slots, + blocked, + ); + if (!freeResources[EResourceType.TECH]?.length || !freeResources[EResourceType.BAY]?.length) + throw new NotFoundException(AppointmentErrorMessages.NO_RESOURCE); + + const { lowestLoad, remaining } = pickByLowestLoad(freeResources, reservations); + const data = { + dealershipId, + customerId, + vehicleId, + serviceTypeId, + startAt: startAtUtc, + endAt: endAtUtc, + status: EAppointmentStatus.CONFIRMED, + }; + + const appointment = await this.bookSlots(lowestLoad, slots, data); + return appointment || (await this.bookSlots(remaining, slots, data)); + } + + @Transactional() + async cancelAppointment(id: number): Promise { + await this.exists(id); + await this.reservationRepo.delete({ appointmentId: id }); + return await this.appointmentRepo.save({ id, status: EAppointmentStatus.CANCELLED }); + } + + async bookSlots( + candidates: Record, + slots: Date[], + data: Partial, + ): Promise { + const { [EResourceType.TECH]: techCandidates, [EResourceType.BAY]: bayCandidates } = candidates; + for (const techId of techCandidates) + for (const bayId of bayCandidates) { + try { + const appointment = await this.reserveResources({ techId, bayId }, slots, data); + if (appointment) return appointment; + } catch (error) { + const { driverError: { sqlMessage, code, errno } = {} } = + error as QueryFailedError; + + if (sqlMessage?.includes(DUPLICATE_VEHICLE_BOOKING_INDEX)) + throw new ConflictException(AppointmentErrorMessages.DUPLICATE_VEHICLE_BOOKING); + + if (code === 'ER_DUP_ENTRY' || errno === 1062) continue; // Ignore duplicate entry error and try next candidate + throw error; + } + } + throw new ConflictException(AppointmentErrorMessages.NO_RESOURCE); + } + + @Transactional({ + propagation: Propagation.REQUIRES_NEW, + isolationLevel: IsolationLevel.READ_COMMITTED, + }) + async reserveResources( + { bayId, techId }: { bayId: number; techId: number }, + slots: Date[], + data: Partial, + ): Promise { + const [lockedTech, lockedBay] = await Promise.all([ + this.appointmentRepo.lockResource(techId, EResourceType.TECH), + this.appointmentRepo.lockResource(bayId, EResourceType.BAY), + ]); + if (!lockedTech || !lockedBay) return null; + + const reservations = slots.flatMap((slotStart) => [ + { + resourceType: EResourceType.TECH, + resourceId: techId, + slotStart, + }, + { + resourceType: EResourceType.BAY, + resourceId: bayId, + slotStart, + }, + ]); + + return await this.appointmentRepo.createAppointment( + { + ...data, + technicianId: techId, + serviceBayId: bayId, + }, + reservations, + ); + } + + private validateTime(startAt: string, dealership: Dealership, durationMinutes: number) { + // Time must be grid-aligned + const startAtUtc = toUtc(startAt); + if (!isGridAligned(startAtUtc)) + throw new BadRequestException(AppointmentErrorMessages.INVALID_GRID_ALIGNED); + + // Time must be within business hours + const { openTime, closeTime, timezone } = dealership; + const dateStr = dayjs(startAtUtc).format('YYYY-MM-DD'); + const openTimeUtc = toUtc(dateStr, openTime, timezone); + const closeTimeUtc = toUtc(dateStr, closeTime, timezone); + const endAtUtc = dayjs(startAtUtc).add(durationMinutes, 'minute').toDate(); + if (startAtUtc < openTimeUtc || endAtUtc > closeTimeUtc) + throw new BadRequestException(AppointmentErrorMessages.INVALID_BUSINESS_HOURS); + + return { startAtUtc, endAtUtc }; + } } diff --git a/src/modules/appointment/contants/appointment.contanst.ts b/src/modules/appointment/constants/appointment.constant.ts similarity index 67% rename from src/modules/appointment/contants/appointment.contanst.ts rename to src/modules/appointment/constants/appointment.constant.ts index 82558a6..a7612d0 100644 --- a/src/modules/appointment/contants/appointment.contanst.ts +++ b/src/modules/appointment/constants/appointment.constant.ts @@ -1,6 +1,7 @@ export const SLOT_SIZE_MINUTES = 15; export const GRID_ANCHOR_UTC = new Date('1970-01-01T00:00:00.000Z').getTime(); - +export const TOP_K_RESOURCES = 5; +export const DUPLICATE_VEHICLE_BOOKING_INDEX = 'sch_appointment_UQ_vehicleId_startAt_active'; export enum EResourceType { TECH = 'TECH', BAY = 'BAY', diff --git a/src/modules/appointment/constants/appointment.message.ts b/src/modules/appointment/constants/appointment.message.ts new file mode 100644 index 0000000..78c2af2 --- /dev/null +++ b/src/modules/appointment/constants/appointment.message.ts @@ -0,0 +1,9 @@ +export const AppointmentErrorMessages = { + NOT_FOUND: 'Appointment not found', + INVALID_TIME_FORMAT: 'Appointment start time must be in ISO8601 format', + INVALID_GRID_ALIGNED: 'Appointment start time does not follow grid alignment', + INVALID_BUSINESS_HOURS: 'Appointment time must be within business hours', + NO_RESOURCE: + 'No qualified technician or active service bay is available for the requested time. Get the latest availability and retry.', + DUPLICATE_VEHICLE_BOOKING: 'This vehicle already has an appointment at the requested time', +} as const; diff --git a/src/modules/appointment/dtos/create-appointment.dto.ts b/src/modules/appointment/dtos/create-appointment.dto.ts new file mode 100644 index 0000000..8d9b462 --- /dev/null +++ b/src/modules/appointment/dtos/create-appointment.dto.ts @@ -0,0 +1,32 @@ +import { IsDateString, IsInt, IsPositive, Matches } from 'class-validator'; +import { AppointmentErrorMessages } from '../constants/appointment.message'; + +export class CreateAppointmentDto { + /** + * @example 1 + */ + @IsInt() + @IsPositive() + dealershipId: number; + /** + * @example 1 + */ + @IsInt() + @IsPositive() + vehicleId: number; + /** + * @example 1 + */ + @IsInt() + @IsPositive() + serviceTypeId: number; + /** + * ISO8601 format + * @example 2026-07-20T10:00:00.000Z + */ + @IsDateString({ strict: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, { + message: AppointmentErrorMessages.INVALID_TIME_FORMAT, + }) + startAt: string; +} diff --git a/src/modules/appointment/dtos/get-availability.dto.ts b/src/modules/appointment/dtos/get-availability.dto.ts index f278b46..d79a40b 100644 --- a/src/modules/appointment/dtos/get-availability.dto.ts +++ b/src/modules/appointment/dtos/get-availability.dto.ts @@ -1,11 +1,12 @@ -import { IsDateString, IsNotEmpty, IsNumber, IsString, MaxLength } from 'class-validator'; +import { IsDateString, IsInt, IsNotEmpty, IsPositive, IsString, MaxLength } from 'class-validator'; export class GetAvailabilityDto { /** * @example 1 */ @IsNotEmpty() - @IsNumber() + @IsInt() + @IsPositive() serviceTypeId: number; /** YYYY-MM-DD format * @example "2023-08-15" diff --git a/src/modules/appointment/dtos/search-appointment.dto.ts b/src/modules/appointment/dtos/search-appointment.dto.ts new file mode 100644 index 0000000..446ad6e --- /dev/null +++ b/src/modules/appointment/dtos/search-appointment.dto.ts @@ -0,0 +1,38 @@ +import { IsInt, IsOptional, IsPositive, Max } from 'class-validator'; + +export class SearchAppointmentDto { + /** + * @example 1 + */ + @IsOptional() + @IsInt() + @IsPositive() + dealershipId?: number; + + /** + * @example 1 + */ + @IsOptional() + @IsInt() + @IsPositive() + vehicleId?: number; + + /** + * @default 1 + * @example 1 + */ + @IsOptional() + @IsInt() + @IsPositive() + page = 1; + + /** + * @default 20 + * @example 20 + */ + @IsOptional() + @IsInt() + @IsPositive() + @Max(100) + limit = 20; +} diff --git a/src/modules/appointment/entities/appointment.entity.ts b/src/modules/appointment/entities/appointment.entity.ts index 1a29d7f..8304c41 100644 --- a/src/modules/appointment/entities/appointment.entity.ts +++ b/src/modules/appointment/entities/appointment.entity.ts @@ -1,60 +1,49 @@ import { BaseEntity } from '@src/database/entities/base.entity'; -import { Customer } from '@src/modules/customer/entities/customer.entity'; import { Dealership } from '@src/modules/dealership/entities/dealership.entity'; import { ServiceBay } from '@src/modules/service-bay/entities/service-bay.entity'; import { ServiceType } from '@src/modules/service-type/entities/service-type.entity'; import { Technician } from '@src/modules/technician/entities/technician.entity'; import { Vehicle } from '@src/modules/vehicle/entities/vehicle.entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm'; import { - Column, - Entity, - JoinTable, - ManyToMany, - ManyToOne, - Unique, -} from 'typeorm'; -import { EAppointmentStatus } from '../contants/appointment.contanst'; + DUPLICATE_VEHICLE_BOOKING_INDEX, + EAppointmentStatus, +} from '../constants/appointment.constant'; +import { ResourceReservation } from './resource-reservation.entity'; @Entity() -@Unique('uq_vehicle_start', ['vehicleId', 'startAt']) +@Unique(DUPLICATE_VEHICLE_BOOKING_INDEX, ['vehicleId', 'startAt', 'active']) +@Index('sch_appointment_NC_dealershipId', ['dealershipId']) export class Appointment extends BaseEntity { @ManyToOne(() => Dealership, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'dealership_id' }) dealership: Dealership; @Column({ name: 'dealership_id', type: 'int', unsigned: true }) dealershipId: number; - @ManyToOne(() => Customer, { createForeignKeyConstraints: false }) - customer: Customer; - @Column({ name: 'customer_id', type: 'int', unsigned: true }) - customerId: number; - @ManyToOne(() => Vehicle, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'vehicle_id' }) vehicle: Vehicle; @Column({ name: 'vehicle_id', type: 'int', unsigned: true }) vehicleId: number; @ManyToOne(() => Technician, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'technician_id' }) technician: Technician; @Column({ name: 'technician_id', type: 'int', unsigned: true }) technicianId: number; @ManyToOne(() => ServiceBay, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'service_bay_id' }) serviceBay: ServiceBay; @Column({ name: 'service_bay_id', type: 'int', unsigned: true }) serviceBayId: number; - @ManyToMany(() => ServiceType, { createForeignKeyConstraints: false }) - @JoinTable({ - name: 'appointment_service_type', - joinColumn: { - name: 'appointmentId', - referencedColumnName: 'id', - }, - inverseJoinColumn: { - name: 'serviceTypeId', - referencedColumnName: 'id', - }, - }) - serviceTypes: ServiceType[]; + + @ManyToOne(() => ServiceType, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'service_type_id' }) + serviceType: ServiceType; + @Column({ name: 'service_type_id', type: 'int', unsigned: true }) + serviceTypeId: number; @Column({ name: 'start_at', type: 'datetime' }) startAt: Date; @@ -64,4 +53,18 @@ export class Appointment extends BaseEntity { @Column({ type: 'tinyint' }) status: EAppointmentStatus; + + @Column({ + type: 'tinyint', + nullable: true, + select: false, + insert: false, + update: false, + asExpression: `if(status <> ${EAppointmentStatus.CANCELLED}, 1, NULL)`, + generatedType: 'STORED', + }) + active: number | null; + + @OneToMany(() => ResourceReservation, (reservation) => reservation.appointment) + reservations: ResourceReservation[]; } diff --git a/src/modules/appointment/entities/resource-reservation.entity.ts b/src/modules/appointment/entities/resource-reservation.entity.ts index a4f9d82..abff30a 100644 --- a/src/modules/appointment/entities/resource-reservation.entity.ts +++ b/src/modules/appointment/entities/resource-reservation.entity.ts @@ -1,9 +1,9 @@ -import { Column, Entity, Index, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { EResourceType } from '../constants/appointment.constant'; import { Appointment } from './appointment.entity'; -import { EResourceType } from '../contants/appointment.contanst'; @Entity() -@Index('idx_appointment_id', ['appointmentId']) +@Index('sch_resourceReservation_NC_appointmentId', ['appointmentId']) export class ResourceReservation { @PrimaryColumn({ name: 'resource_type', type: 'nvarchar', length: 10 }) resourceType: EResourceType; @@ -14,7 +14,10 @@ export class ResourceReservation { @PrimaryColumn({ name: 'slot_start', type: 'datetime' }) slotStart: Date; - @ManyToOne(() => Appointment, { createForeignKeyConstraints: false }) + @ManyToOne(() => Appointment, (appointment) => appointment.reservations, { + createForeignKeyConstraints: false, + }) + @JoinColumn({ name: 'appointment_id' }) appointment: Appointment; @Column({ name: 'appointment_id', type: 'int', unsigned: true }) diff --git a/src/modules/appointment/interfaces/appointment-search.type.ts b/src/modules/appointment/interfaces/appointment-search.type.ts new file mode 100644 index 0000000..76dc5f0 --- /dev/null +++ b/src/modules/appointment/interfaces/appointment-search.type.ts @@ -0,0 +1,11 @@ +import { Appointment } from '../entities/appointment.entity'; + +export type AppointmentSearchResult = { + items: Appointment[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +}; diff --git a/src/modules/appointment/interfaces/resource-reservation.type.ts b/src/modules/appointment/interfaces/resource-reservation.type.ts index a3ab7fb..60e8c9e 100644 --- a/src/modules/appointment/interfaces/resource-reservation.type.ts +++ b/src/modules/appointment/interfaces/resource-reservation.type.ts @@ -1,4 +1,4 @@ -import { EResourceType } from '../contants/appointment.contanst'; +import { EResourceType } from '../constants/appointment.constant'; export type Reservation = { resourceType: EResourceType; diff --git a/src/modules/appointment/tests/appointment.helper.spec.ts b/src/modules/appointment/tests/appointment.helper.spec.ts index de7c92d..ce3f2da 100644 --- a/src/modules/appointment/tests/appointment.helper.spec.ts +++ b/src/modules/appointment/tests/appointment.helper.spec.ts @@ -1,6 +1,10 @@ import { mergeSlots, isGridAligned } from '../appointment.helper'; describe('slot.util', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('computes normal slots for 45-minute duration', () => { const startAt = new Date('2026-07-20T10:00:00.000Z'); const slots = mergeSlots(startAt, 45); diff --git a/src/modules/appointment/tests/appointment.service.spec.ts b/src/modules/appointment/tests/appointment.service.spec.ts index 0ef95f8..834d81b 100644 --- a/src/modules/appointment/tests/appointment.service.spec.ts +++ b/src/modules/appointment/tests/appointment.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -7,24 +8,72 @@ import { DealershipService } from '../../dealership/dealership.service'; import { ServiceTypeService } from '../../service-type/service-type.service'; import { ServiceBayService } from '../../service-bay/service-bay.service'; import { TechnicianService } from '../../technician/technician.service'; +import { VehicleService } from '../../vehicle/vehicle.service'; +import { CreateAppointmentDto } from '../dtos/create-appointment.dto'; +import { AppointmentRepository } from '../appointment.repository'; +import { EAppointmentStatus, EResourceType } from '../constants/appointment.constant'; -type MockReservationRepo = Partial, jest.Mock>>; +jest.mock('typeorm-transactional', () => ({ + IsolationLevel: { READ_COMMITTED: 'READ_COMMITTED' }, + Propagation: { REQUIRES_NEW: 'REQUIRES_NEW' }, + Transactional: + () => + (_target: object, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => + descriptor, +})); + +type MockRepo = Partial, jest.Mock>>; describe('AppointmentService', () => { + let moduleRef: TestingModule; let service: AppointmentService; - let reservationRepo: MockReservationRepo; + let reservationRepo: MockRepo; let dealershipService: DealershipService; let serviceTypeService: ServiceTypeService; let serviceBayService: ServiceBayService; let technicianService: TechnicianService; + let vehicleService: VehicleService; + + const appointmentRepoMock = { + exists: jest.fn(), + findOne: jest.fn(), + search: jest.fn(), + lockResource: jest.fn(), + createAppointment: jest.fn(), + save: jest.fn(), + }; + + const fixture = { + id: 99, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + dealershipId: 1, + customerId: 7, + vehicleId: 1, + technicianId: 1, + serviceBayId: 1, + startAt: new Date('2026-07-20T09:00:00.000Z'), + endAt: new Date('2026-07-20T09:30:00.000Z'), + status: 'CONFIRMED', + }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + appointmentRepoMock.exists.mockResolvedValue(false); + appointmentRepoMock.search.mockResolvedValue([[], 0]); + appointmentRepoMock.lockResource.mockResolvedValue({ id: 1 }); + appointmentRepoMock.createAppointment.mockResolvedValue(fixture); + appointmentRepoMock.save.mockResolvedValue(fixture); + appointmentRepoMock.findOne.mockResolvedValue(fixture); + moduleRef = await Test.createTestingModule({ providers: [ AppointmentService, + { + provide: AppointmentRepository, + useValue: appointmentRepoMock, + }, { provide: getRepositoryToken(ResourceReservation), - useValue: { find: jest.fn() }, + useValue: { find: jest.fn(), save: jest.fn(), delete: jest.fn() }, }, { provide: DealershipService, @@ -36,21 +85,26 @@ describe('AppointmentService', () => { }, { provide: ServiceBayService, - useValue: { findByDealershipId: jest.fn() }, + useValue: { findByDealership: jest.fn() }, }, { provide: TechnicianService, - useValue: { findActive: jest.fn() }, + useValue: { findBy: jest.fn() }, + }, + { + provide: VehicleService, + useValue: { findOne: jest.fn() }, }, ], }).compile(); - service = module.get(AppointmentService); - reservationRepo = module.get(getRepositoryToken(ResourceReservation)); - dealershipService = module.get(DealershipService); - serviceTypeService = module.get(ServiceTypeService); - serviceBayService = module.get(ServiceBayService); - technicianService = module.get(TechnicianService); + service = moduleRef.get(AppointmentService); + reservationRepo = moduleRef.get(getRepositoryToken(ResourceReservation)); + dealershipService = moduleRef.get(DealershipService); + serviceTypeService = moduleRef.get(ServiceTypeService); + serviceBayService = moduleRef.get(ServiceBayService); + technicianService = moduleRef.get(TechnicianService); + vehicleService = moduleRef.get(VehicleService); (dealershipService.findOne as jest.Mock).mockResolvedValue({ id: 1, @@ -62,15 +116,29 @@ describe('AppointmentService', () => { id: 1, durationMinutes: 30, }); - (technicianService.findActive as jest.Mock).mockResolvedValue([ - { id: 1, active: true, dealershipId: 1 }, + (vehicleService.findOne as jest.Mock).mockResolvedValue({ + id: 1, + customerId: 7, + }); + (technicianService.findBy as jest.Mock).mockResolvedValue([ + { + id: 1, + active: true, + dealership: { timezone: 'UTC', openTime: '09:00:00', closeTime: '10:00:00' }, + serviceType: [{ id: 1, durationMinutes: 30 }], + }, ]); - (serviceBayService.findByDealershipId as jest.Mock).mockResolvedValue([ + (serviceBayService.findByDealership as jest.Mock).mockResolvedValue([ { id: 1, active: true, dealershipId: 1 }, ]); reservationRepo.find?.mockResolvedValue([]); }); + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); + }); + it('returns aligned times when all resources are free', async () => { const result = await service.getAvailability(1, 1, '2026-07-20'); @@ -81,6 +149,63 @@ describe('AppointmentService', () => { ]); }); + it('search returns paginated appointments without filters', async () => { + appointmentRepoMock.search.mockResolvedValueOnce([[fixture], 1]); + + const result = await service.search({ page: 1, limit: 20 }); + + expect(appointmentRepoMock.search).toHaveBeenCalledWith({ + dealershipId: undefined, + vehicleId: undefined, + skip: 0, + take: 20, + }); + expect(result).toEqual({ + items: [fixture], + pagination: { + page: 1, + limit: 20, + total: 1, + totalPages: 1, + }, + }); + }); + + it('search returns paginated appointments by dealership', async () => { + appointmentRepoMock.search.mockResolvedValueOnce([[fixture], 1]); + + const result = await service.search({ dealershipId: 1, page: 2, limit: 5 }); + + expect(appointmentRepoMock.search).toHaveBeenCalledWith({ + dealershipId: 1, + vehicleId: undefined, + skip: 5, + take: 5, + }); + expect(result).toEqual({ + items: [fixture], + pagination: { + page: 2, + limit: 5, + total: 1, + totalPages: 1, + }, + }); + }); + + it('search forwards both filters when both provided', async () => { + appointmentRepoMock.search.mockResolvedValueOnce([[], 0]); + + await service.search({ dealershipId: 1, vehicleId: 7, page: 3, limit: 10 }); + + expect(appointmentRepoMock.search).toHaveBeenCalledWith({ + dealershipId: 1, + vehicleId: 7, + skip: 20, + take: 10, + }); + }); + it('returns empty when fully booked', async () => { reservationRepo.find?.mockResolvedValue([ { @@ -142,18 +267,28 @@ describe('AppointmentService', () => { closeTime: '10:00:00', timezone: 'UTC', }); + (technicianService.findBy as jest.Mock).mockResolvedValue([ + { + id: 1, + active: true, + dealership: { timezone: 'UTC', openTime: '09:07:00', closeTime: '10:00:00' }, + serviceType: [{ id: 1, durationMinutes: 30 }], + }, + ]); const result = await service.getAvailability(1, 1, '2026-07-20'); expect(result).toEqual(['2026-07-20T09:15:00.000Z', '2026-07-20T09:30:00.000Z']); }); it('returns UTC slots converted from dealership timezone', async () => { - (dealershipService.findOne as jest.Mock).mockResolvedValue({ - id: 1, - openTime: '09:00:00', - closeTime: '10:00:00', - timezone: 'Asia/Tokyo', - }); + (technicianService.findBy as jest.Mock).mockResolvedValue([ + { + id: 1, + active: true, + dealership: { timezone: 'Asia/Tokyo', openTime: '09:00:00', closeTime: '10:00:00' }, + serviceType: [{ id: 1, durationMinutes: 30 }], + }, + ]); const result = await service.getAvailability(1, 1, '2026-07-20'); expect(result).toEqual([ @@ -162,4 +297,157 @@ describe('AppointmentService', () => { '2026-07-20T00:30:00.000Z', ]); }); + + it('books appointment in transaction when resources free', async () => { + const dto: CreateAppointmentDto = { + dealershipId: 1, + vehicleId: 1, + serviceTypeId: 1, + startAt: '2026-07-20T09:00:00.000Z', + }; + + const result = await service.createAppointment(dto); + + const [existsArgRaw] = appointmentRepoMock.exists.mock.calls[0] as [unknown]; + const existsArg = existsArgRaw as { where: { vehicleId: number; startAt: Date } }; + expect(existsArg.where.vehicleId).toBe(1); + expect(existsArg.where.startAt).toEqual(new Date('2026-07-20T09:00:00.000Z')); + expect(appointmentRepoMock.lockResource).toHaveBeenCalledTimes(2); + expect(appointmentRepoMock.lockResource).toHaveBeenNthCalledWith(1, 1, EResourceType.TECH); + expect(appointmentRepoMock.lockResource).toHaveBeenNthCalledWith(2, 1, EResourceType.BAY); + expect(appointmentRepoMock.createAppointment).toHaveBeenCalledWith( + { + dealershipId: 1, + customerId: 7, + vehicleId: 1, + serviceTypeId: 1, + startAt: new Date('2026-07-20T09:00:00.000Z'), + endAt: new Date('2026-07-20T09:30:00.000Z'), + status: EAppointmentStatus.CONFIRMED, + technicianId: 1, + serviceBayId: 1, + }, + [ + { + resourceType: 'TECH', + resourceId: 1, + slotStart: new Date('2026-07-20T09:00:00.000Z'), + }, + { + resourceType: 'BAY', + resourceId: 1, + slotStart: new Date('2026-07-20T09:00:00.000Z'), + }, + { + resourceType: 'TECH', + resourceId: 1, + slotStart: new Date('2026-07-20T09:15:00.000Z'), + }, + { + resourceType: 'BAY', + resourceId: 1, + slotStart: new Date('2026-07-20T09:15:00.000Z'), + }, + ], + ); + expect(result).toEqual(fixture); + }); + + it('throws 409 when the vehicle already has a non-cancelled appointment at the same time', async () => { + appointmentRepoMock.exists.mockResolvedValueOnce(true); + + await expect( + service.createAppointment({ + dealershipId: 1, + vehicleId: 1, + serviceTypeId: 1, + startAt: '2026-07-20T09:00:00.000Z', + }), + ).rejects.toBeInstanceOf(ConflictException); + + expect(appointmentRepoMock.createAppointment).not.toHaveBeenCalled(); + expect(appointmentRepoMock.lockResource).not.toHaveBeenCalled(); + }); + + it('findOne returns appointment with relations', async () => { + const relationsFixture = { + ...fixture, + dealership: { id: 1 }, + customer: { id: 7 }, + vehicle: { id: 1 }, + technician: { id: 1 }, + serviceBay: { id: 1 }, + serviceType: { id: 1 }, + reservations: [], + }; + appointmentRepoMock.findOne.mockResolvedValue(relationsFixture); + + const result = await service.findOne(99); + + expect(result).toEqual(relationsFixture); + expect(appointmentRepoMock.findOne).toHaveBeenCalledWith({ + where: { id: 99 }, + relations: { + dealership: true, + vehicle: { customer: true }, + technician: true, + serviceBay: true, + serviceType: true, + reservations: true, + }, + }); + }); + + it('findOne throws NotFoundException when missing', async () => { + appointmentRepoMock.findOne.mockResolvedValue(null); + + await expect(service.findOne(404)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('cancelAppointment sets status cancelled and clears reservations', async () => { + const cancelledFixture = { + ...fixture, + status: EAppointmentStatus.CANCELLED, + reservations: [], + }; + + appointmentRepoMock.exists.mockResolvedValue(true); + appointmentRepoMock.save.mockResolvedValueOnce(cancelledFixture); + reservationRepo.delete?.mockResolvedValue({ affected: 2 }); + + const result = await service.cancelAppointment(99); + + expect(reservationRepo.delete).toHaveBeenCalledWith({ appointmentId: 99 }); + expect(appointmentRepoMock.save).toHaveBeenCalledWith({ + id: 99, + status: EAppointmentStatus.CANCELLED, + }); + expect(result).toEqual(cancelledFixture); + }); + + it('throws 400 when startAt is off-grid', async () => { + const dto: CreateAppointmentDto = { + dealershipId: 1, + vehicleId: 1, + serviceTypeId: 1, + startAt: '2026-07-20T09:07:00.000Z', + }; + + await expect(service.createAppointment(dto)).rejects.toBeInstanceOf(BadRequestException); + expect(appointmentRepoMock.createAppointment).toHaveBeenCalledTimes(0); + }); + + it('throws 404 when no active bays or qualified technicians', async () => { + (technicianService.findBy as jest.Mock).mockResolvedValue([]); + + const dto: CreateAppointmentDto = { + dealershipId: 1, + vehicleId: 1, + serviceTypeId: 1, + startAt: '2026-07-20T09:00:00.000Z', + }; + + await expect(service.createAppointment(dto)).rejects.toBeInstanceOf(NotFoundException); + expect(appointmentRepoMock.createAppointment).toHaveBeenCalledTimes(0); + }); }); diff --git a/src/modules/customer/tests/customer.service.spec.ts b/src/modules/customer/tests/customer.service.spec.ts index c0cdb2c..4d3e134 100644 --- a/src/modules/customer/tests/customer.service.spec.ts +++ b/src/modules/customer/tests/customer.service.spec.ts @@ -1,15 +1,13 @@ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { - MockRepository, - mockRepository, -} from '../../../common/testing/repository.mock'; +import { MockRepository, mockRepository } from '../../../common/testing/repository.mock'; import { CustomerService } from '../customer.service'; import { CreateCustomerDto } from '../dtos/create-customer.dto'; import { Customer } from '../entities/customer.entity'; describe('CustomerService', () => { + let moduleRef: TestingModule; let service: CustomerService; let repo: MockRepository; @@ -28,7 +26,7 @@ describe('CustomerService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ CustomerService, { @@ -38,8 +36,13 @@ describe('CustomerService', () => { ], }).compile(); - service = module.get(CustomerService); - repo = module.get(getRepositoryToken(Customer)); + service = moduleRef.get(CustomerService); + repo = moduleRef.get(getRepositoryToken(Customer)); + }); + + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); }); it('findAll returns empty list', async () => { @@ -75,8 +78,6 @@ describe('CustomerService', () => { it('update throws NotFoundException when missing', async () => { repo.exists?.mockResolvedValue(false); - await expect(service.update(99, {})).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect(service.update(99, {})).rejects.toBeInstanceOf(NotFoundException); }); }); diff --git a/src/modules/dealership/dealership.controller.ts b/src/modules/dealership/dealership.controller.ts index 8bbd838..f6b3c1f 100644 --- a/src/modules/dealership/dealership.controller.ts +++ b/src/modules/dealership/dealership.controller.ts @@ -1,13 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, -} from '@nestjs/common'; -// import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; import { DealershipService } from './dealership.service'; import { CreateDealershipDto } from './dtos/create-dealership.dto'; import { UpdateDealershipDto } from './dtos/update-dealership.dto'; @@ -33,10 +24,7 @@ export class DealershipController { } @Patch(':id') - update( - @Param('id') id: number, - @Body() dto: UpdateDealershipDto, - ): Promise { + update(@Param('id') id: number, @Body() dto: UpdateDealershipDto): Promise { return this.dealershipService.update(id, dto); } diff --git a/src/modules/dealership/dealership.service.ts b/src/modules/dealership/dealership.service.ts index 22c99d2..943de9b 100644 --- a/src/modules/dealership/dealership.service.ts +++ b/src/modules/dealership/dealership.service.ts @@ -39,8 +39,6 @@ export class DealershipService { async update(id: number, dto: UpdateDealershipDto): Promise { const dealership = await this.findOne(id); - if (!dealership) throw new NotFoundException(DealershipErrorMessages.NOT_FOUND); - const entity = { ...dealership, ...dto }; if (entity.openTime >= entity.closeTime) throw new BadRequestException(DealershipErrorMessages.INVALID_OPEN_CLOSE_TIME); diff --git a/src/modules/dealership/entities/dealership.entity.ts b/src/modules/dealership/entities/dealership.entity.ts index ee8fd1e..6d18e77 100644 --- a/src/modules/dealership/entities/dealership.entity.ts +++ b/src/modules/dealership/entities/dealership.entity.ts @@ -1,5 +1,7 @@ import { BaseEntity } from '@src/database/entities/base.entity'; -import { Column, Entity } from 'typeorm'; +import { ServiceBay } from '@src/modules/service-bay/entities/service-bay.entity'; +import { Technician } from '@src/modules/technician/entities/technician.entity'; +import { Column, Entity, OneToMany } from 'typeorm'; @Entity() export class Dealership extends BaseEntity { @@ -17,4 +19,8 @@ export class Dealership extends BaseEntity { openTime: string; @Column({ name: 'close_time', type: 'time' }) closeTime: string; + @OneToMany(() => ServiceBay, (serviceBay) => serviceBay.dealership) + serviceBays: ServiceBay[]; + @OneToMany(() => Technician, (technician) => technician.dealership) + technicians: Technician[]; } diff --git a/src/modules/dealership/tests/dealership.controller.spec.ts b/src/modules/dealership/tests/dealership.controller.spec.ts deleted file mode 100644 index 908462b..0000000 --- a/src/modules/dealership/tests/dealership.controller.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; -import { App } from 'supertest/types'; -import { DealershipController } from '../dealership.controller'; -import { DealershipService } from '../dealership.service'; - -describe('DealershipController', () => { - let app: INestApplication; - - const serviceMock = { - findAll: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - update: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [DealershipController], - providers: [{ provide: DealershipService, useValue: serviceMock }], - }).compile(); - - app = module.createNestApplication(); - app.setGlobalPrefix('api'); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - jest.clearAllMocks(); - }); - - it('GET /api/dealerships returns empty array', async () => { - serviceMock.findAll.mockResolvedValue([]); - - await request(app.getHttpServer()) - .get('/api/dealerships') - .expect(200) - .expect([]); - - expect(serviceMock.findAll).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/modules/dealership/tests/dealership.service.spec.ts b/src/modules/dealership/tests/dealership.service.spec.ts index 17afd61..079ffd1 100644 --- a/src/modules/dealership/tests/dealership.service.spec.ts +++ b/src/modules/dealership/tests/dealership.service.spec.ts @@ -1,15 +1,13 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { - MockRepository, - mockRepository, -} from '../../../common/testing/repository.mock'; +import { MockRepository, mockRepository } from '../../../common/testing/repository.mock'; import { DealershipService } from '../dealership.service'; import { CreateDealershipDto } from '../dtos/create-dealership.dto'; import { Dealership } from '../entities/dealership.entity'; describe('DealershipService', () => { + let moduleRef: TestingModule; let service: DealershipService; let repository: MockRepository; @@ -31,7 +29,7 @@ describe('DealershipService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ DealershipService, { @@ -41,8 +39,13 @@ describe('DealershipService', () => { ], }).compile(); - service = module.get(DealershipService); - repository = module.get(getRepositoryToken(Dealership)); + service = moduleRef.get(DealershipService); + repository = moduleRef.get(getRepositoryToken(Dealership)); + }); + + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); }); it('findAll returns empty list', async () => { @@ -69,6 +72,19 @@ describe('DealershipService', () => { await expect(service.findOne(2)).rejects.toBeInstanceOf(NotFoundException); }); + it('exists resolves when entity exists', async () => { + repository.exists?.mockResolvedValue(true); + + await expect(service.exists(1)).resolves.toBeUndefined(); + expect(repository.exists).toHaveBeenCalledWith({ where: { id: 1 } }); + }); + + it('exists throws when entity does not exist', async () => { + repository.exists?.mockResolvedValue(false); + + await expect(service.exists(2)).rejects.toBeInstanceOf(NotFoundException); + }); + it('create persists entity', async () => { repository.save?.mockResolvedValue(dealershipFixture); const result = await service.create(dealershipDto); @@ -76,25 +92,48 @@ describe('DealershipService', () => { expect(result).toEqual(dealershipFixture); }); + it('create throws when openTime is after closeTime', async () => { + const invalidDto = { ...dealershipDto, openTime: '18:00', closeTime: '08:00' }; + await expect(service.create(invalidDto)).rejects.toBeInstanceOf(BadRequestException); + }); + it('update merges and persists entity', async () => { - repository.exists?.mockResolvedValue(true); + repository.findOne?.mockResolvedValue(dealershipFixture); repository.save?.mockResolvedValue({ - ...dealershipDto, + ...dealershipFixture, city: 'Dallas', }); const result = await service.update(1, { city: 'Dallas' }); - expect(repository.exists).toHaveBeenCalledWith({ where: { id: 1 } }); - expect(repository.save).toHaveBeenCalledWith({ id: 1, city: 'Dallas' }); + expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(repository.save).toHaveBeenCalledWith({ + ...dealershipFixture, + city: 'Dallas', + }); expect(result.city).toBe('Dallas'); }); it('update throws when entity does not exist', async () => { - repository.exists?.mockResolvedValue(false); + repository.findOne?.mockResolvedValue(null); + + await expect(service.update(1, { city: 'Dallas' })).rejects.toBeInstanceOf(NotFoundException); + }); + + it('delete removes entity', async () => { + repository.findOne?.mockResolvedValue(dealershipFixture); + repository.delete?.mockResolvedValue({ affected: 1 }); + + const result = await service.delete(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(repository.delete).toHaveBeenCalledWith(1); + expect(result).toEqual(dealershipFixture); + }); + + it('delete throws when entity does not exist', async () => { + repository.findOne?.mockResolvedValue(null); - await expect(service.update(1, { city: 'Dallas' })).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect(service.delete(1)).rejects.toBeInstanceOf(NotFoundException); }); }); diff --git a/src/modules/service-bay/dtos/get-by-dealership.dto.ts b/src/modules/service-bay/dtos/get-by-dealership.dto.ts new file mode 100644 index 0000000..70d419d --- /dev/null +++ b/src/modules/service-bay/dtos/get-by-dealership.dto.ts @@ -0,0 +1,10 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class GetServiceBayQuery { + @IsOptional() + @IsBoolean() + @Type(() => String) + @Transform(({ value }) => ['1', 'true'].includes(value)) + active?: boolean; +} diff --git a/src/modules/service-bay/entities/service-bay.entity.ts b/src/modules/service-bay/entities/service-bay.entity.ts index db5f824..8dd1741 100644 --- a/src/modules/service-bay/entities/service-bay.entity.ts +++ b/src/modules/service-bay/entities/service-bay.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@src/database/entities/base.entity'; -import { Column, Entity, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Dealership } from '../../dealership/entities/dealership.entity'; @Entity() +@Index('sch_service_bay_NC_dealershipId', ['dealershipId']) export class ServiceBay extends BaseEntity { @Column({ type: 'nvarchar', length: 255 }) name: string; @@ -11,8 +12,9 @@ export class ServiceBay extends BaseEntity { active: boolean; @ManyToOne(() => Dealership, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'dealership_id' }) dealership: Dealership; - @Column({ type: 'int', unsigned: true }) + @Column({ name: 'dealership_id', type: 'int', unsigned: true }) dealershipId: number; } diff --git a/src/modules/service-bay/service-bay.controller.ts b/src/modules/service-bay/service-bay.controller.ts index 083691b..8e8c128 100644 --- a/src/modules/service-bay/service-bay.controller.ts +++ b/src/modules/service-bay/service-bay.controller.ts @@ -1,16 +1,9 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { CreateServiceBayDto } from './dtos/create-service-bay.dto'; import { UpdateServiceBayDto } from './dtos/update-service-bay.dto'; import { ServiceBay } from './entities/service-bay.entity'; import { ServiceBayService } from './service-bay.service'; +import { GetServiceBayQuery } from './dtos/get-by-dealership.dto'; @Controller('service-bays') export class ServiceBayController { @@ -24,8 +17,9 @@ export class ServiceBayController { @Get('dealership/:dealershipId') findByDealership( @Param('dealershipId') dealershipId: number, + @Query() { active }: GetServiceBayQuery, ): Promise { - return this.serviceBayService.findByDealershipId(dealershipId); + return this.serviceBayService.findByDealership(dealershipId, active); } @Get(':id') @@ -39,10 +33,7 @@ export class ServiceBayController { } @Patch(':id') - update( - @Param('id') id: number, - @Body() dto: UpdateServiceBayDto, - ): Promise { + update(@Param('id') id: number, @Body() dto: UpdateServiceBayDto): Promise { return this.serviceBayService.update(id, dto); } diff --git a/src/modules/service-bay/service-bay.service.ts b/src/modules/service-bay/service-bay.service.ts index 707a24f..6ac684e 100644 --- a/src/modules/service-bay/service-bay.service.ts +++ b/src/modules/service-bay/service-bay.service.ts @@ -19,9 +19,9 @@ export class ServiceBayService { return this.bayRepo.find({ relations: { dealership: true } }); } - findByDealershipId(dealershipId: number): Promise { + findByDealership(dealershipId: number, active?: boolean): Promise { return this.bayRepo.find({ - where: { dealershipId }, + where: { dealershipId, ...(active !== undefined ? { active } : {}) }, relations: { dealership: true }, }); } diff --git a/src/modules/service-bay/tests/service-bay.service.spec.ts b/src/modules/service-bay/tests/service-bay.service.spec.ts index 7a8ce56..d143c2c 100644 --- a/src/modules/service-bay/tests/service-bay.service.spec.ts +++ b/src/modules/service-bay/tests/service-bay.service.spec.ts @@ -1,16 +1,14 @@ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { - MockRepository, - mockRepository, -} from '../../../common/testing/repository.mock'; +import { MockRepository, mockRepository } from '../../../common/testing/repository.mock'; import { ServiceBayService } from '../service-bay.service'; import { CreateServiceBayDto } from '../dtos/create-service-bay.dto'; import { ServiceBay } from '../entities/service-bay.entity'; import { DealershipService } from '../../dealership/dealership.service'; describe('ServiceBayService', () => { + let moduleRef: TestingModule; let service: ServiceBayService; let repo: MockRepository; let dealershipService: DealershipService; @@ -30,7 +28,7 @@ describe('ServiceBayService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ ServiceBayService, { @@ -44,14 +42,19 @@ describe('ServiceBayService', () => { ], }).compile(); - service = module.get(ServiceBayService); - repo = module.get(getRepositoryToken(ServiceBay)); - dealershipService = module.get(DealershipService); + service = moduleRef.get(ServiceBayService); + repo = moduleRef.get(getRepositoryToken(ServiceBay)); + dealershipService = moduleRef.get(DealershipService); }); - it('findByDealershipId returns empty list', async () => { + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); + }); + + it('findByDealership returns empty list', async () => { repo.find?.mockResolvedValue([]); - expect(await service.findByDealershipId(1)).toEqual([]); + expect(await service.findByDealership(1)).toEqual([]); expect(repo.find).toHaveBeenCalledWith({ where: { dealershipId: 1 }, relations: { dealership: true }, @@ -98,9 +101,7 @@ describe('ServiceBayService', () => { it('update throws NotFoundException when missing', async () => { repo.exists?.mockResolvedValue(false); - await expect(service.update(99, {})).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect(service.update(99, {})).rejects.toBeInstanceOf(NotFoundException); }); it('delete removes and returns entity', async () => { diff --git a/src/modules/service-type/constants/service-type.message.ts b/src/modules/service-type/constants/service-type.message.ts index ae21f80..cf271c6 100644 --- a/src/modules/service-type/constants/service-type.message.ts +++ b/src/modules/service-type/constants/service-type.message.ts @@ -1,4 +1,4 @@ -import { SLOT_SIZE_MINUTES } from '@src/modules/appointment/contants/appointment.contanst'; +import { SLOT_SIZE_MINUTES } from '@src/modules/appointment/constants/appointment.constant'; export const ServiceTypeErrorMessages = { NOT_FOUND: 'Service type not found', diff --git a/src/modules/service-type/service-type.helper.ts b/src/modules/service-type/service-type.helper.ts index f177138..c4a72b7 100644 --- a/src/modules/service-type/service-type.helper.ts +++ b/src/modules/service-type/service-type.helper.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { ServiceTypeErrorMessages } from './constants/service-type.message'; -import { SLOT_SIZE_MINUTES } from '../appointment/contants/appointment.contanst'; +import { SLOT_SIZE_MINUTES } from '../appointment/constants/appointment.constant'; export function validateDuration(minutes: number, slotSize = SLOT_SIZE_MINUTES): void { if (minutes <= 0 || minutes % slotSize !== 0) diff --git a/src/modules/service-type/service-type.service.ts b/src/modules/service-type/service-type.service.ts index 643fbcf..5f99798 100644 --- a/src/modules/service-type/service-type.service.ts +++ b/src/modules/service-type/service-type.service.ts @@ -26,12 +26,13 @@ export class ServiceTypeService { return serviceType; } - async findActive(ids: number[]): Promise { + async findByIds(ids: number[]): Promise { const serviceTypes = await this.serviceTypeRepo.find({ where: { id: In(ids) }, }); return serviceTypes; } + async exists(id: number): Promise { const exists = await this.serviceTypeRepo.exists({ where: { id } }); if (!exists) throw new NotFoundException(ServiceTypeErrorMessages.NOT_FOUND); diff --git a/src/modules/service-type/tests/service-type.service.spec.ts b/src/modules/service-type/tests/service-type.service.spec.ts index 87c795a..b31401a 100644 --- a/src/modules/service-type/tests/service-type.service.spec.ts +++ b/src/modules/service-type/tests/service-type.service.spec.ts @@ -8,6 +8,7 @@ import { ServiceTypeService } from '../service-type.service'; import { ServiceTypeErrorMessages } from '../constants/service-type.message'; describe('ServiceTypeService', () => { + let moduleRef: TestingModule; let service: ServiceTypeService; let repo: MockRepository; @@ -25,7 +26,7 @@ describe('ServiceTypeService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ ServiceTypeService, { @@ -35,8 +36,13 @@ describe('ServiceTypeService', () => { ], }).compile(); - service = module.get(ServiceTypeService); - repo = module.get(getRepositoryToken(ServiceType)); + service = moduleRef.get(ServiceTypeService); + repo = moduleRef.get(getRepositoryToken(ServiceType)); + }); + + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); }); it('findAll returns empty list', async () => { @@ -75,9 +81,7 @@ describe('ServiceTypeService', () => { it('create throws BadRequestException when duration is invalid', async () => { const invalidDto = { ...dto, durationMinutes: 7 }; - await expect(service.create(invalidDto)).rejects.toThrowError( - ServiceTypeErrorMessages.INVALID_DURATION, - ); + expect(() => service.create(invalidDto)).toThrow(ServiceTypeErrorMessages.INVALID_DURATION); }); it('update saves and returns entity', async () => { diff --git a/src/modules/technician/dtos/get-by-dealership.dto.ts b/src/modules/technician/dtos/get-by-dealership.dto.ts new file mode 100644 index 0000000..1b33dfd --- /dev/null +++ b/src/modules/technician/dtos/get-by-dealership.dto.ts @@ -0,0 +1,10 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class GetTechnicianQuery { + @IsOptional() + @IsBoolean() + @Type(() => String) + @Transform(({ value }) => ['1', 'true'].includes(value)) + active?: boolean; +} diff --git a/src/modules/technician/entities/technician.entity.ts b/src/modules/technician/entities/technician.entity.ts index 4b0201a..4713651 100644 --- a/src/modules/technician/entities/technician.entity.ts +++ b/src/modules/technician/entities/technician.entity.ts @@ -1,9 +1,10 @@ import { BaseEntity } from '@src/database/entities/base.entity'; import { ServiceType } from '@src/modules/service-type/entities/service-type.entity'; -import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from 'typeorm'; import { Dealership } from '../../dealership/entities/dealership.entity'; @Entity() +@Index('sch_technician_NC_dealershipId', ['dealershipId']) export class Technician extends BaseEntity { @Column({ type: 'nvarchar', length: 255 }) name: string; @@ -12,8 +13,9 @@ export class Technician extends BaseEntity { active: boolean; @ManyToOne(() => Dealership, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'dealership_id' }) dealership: Dealership; - @Column({ type: 'int', unsigned: true }) + @Column({ name: 'dealership_id', type: 'int', unsigned: true }) dealershipId: number; @ManyToMany(() => ServiceType, { @@ -23,11 +25,11 @@ export class Technician extends BaseEntity { @JoinTable({ name: 'technician_service_type', joinColumn: { - name: 'technicianId', + name: 'technician_id', referencedColumnName: 'id', }, inverseJoinColumn: { - name: 'serviceTypeId', + name: 'service_type_id', referencedColumnName: 'id', }, }) diff --git a/src/modules/technician/technician.controller.ts b/src/modules/technician/technician.controller.ts index 9baf3dc..252516e 100644 --- a/src/modules/technician/technician.controller.ts +++ b/src/modules/technician/technician.controller.ts @@ -1,16 +1,9 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { CreateTechnicianDto } from './dtos/create-technician.dto'; import { UpdateTechnicianDto } from './dtos/update-technician.dto'; import { Technician } from './entities/technician.entity'; import { TechnicianService } from './technician.service'; +import { GetTechnicianQuery } from './dtos/get-by-dealership.dto'; @Controller('technicians') export class TechnicianController { @@ -26,16 +19,21 @@ export class TechnicianController { return this.technicianService.findOne(id); } + @Get('dealership/:dealershipId') + findByDealership( + @Param('dealershipId') dealershipId: number, + @Query() { active }: GetTechnicianQuery, + ): Promise { + return this.technicianService.findBy({ dealershipId }, active); + } + @Post() create(@Body() dto: CreateTechnicianDto): Promise { return this.technicianService.create(dto); } @Patch(':id') - update( - @Param('id') id: number, - @Body() dto: UpdateTechnicianDto, - ): Promise { + update(@Param('id') id: number, @Body() dto: UpdateTechnicianDto): Promise { return this.technicianService.update(id, dto); } @@ -45,10 +43,8 @@ export class TechnicianController { } @Get('/qualifications/:serviceTypeId') - findQualification( - @Param('serviceTypeId') serviceTypeId: number, - ): Promise { - return this.technicianService.findActive({ serviceTypeId }); + findQualification(@Param('serviceTypeId') serviceTypeId: number): Promise { + return this.technicianService.findBy({ serviceTypeId }, true); } @Post(':id/qualifications/:serviceTypeId') diff --git a/src/modules/technician/technician.service.ts b/src/modules/technician/technician.service.ts index 3d9ce52..b5460b7 100644 --- a/src/modules/technician/technician.service.ts +++ b/src/modules/technician/technician.service.ts @@ -30,19 +30,22 @@ export class TechnicianService { return tech; } - async findActive({ - serviceTypeId, - dealershipId, - }: { - serviceTypeId?: number; - dealershipId?: number; - }): Promise { + async findBy( + { + serviceTypeId, + dealershipId, + }: { + serviceTypeId?: number; + dealershipId?: number; + }, + active?: boolean, + ): Promise { if (!serviceTypeId && !dealershipId) return [] as Technician[]; return this.technicianRepo.find({ where: { dealershipId, ...(serviceTypeId ? { serviceType: { id: serviceTypeId } } : {}), - active: true, + ...(active !== undefined ? { active } : {}), }, relations: { dealership: true, serviceType: true }, }); @@ -83,19 +86,13 @@ export class TechnicianService { return tech; } - async addQualification( - technicianId: number, - serviceTypeId: number, - ): Promise { + async addQualification(technicianId: number, serviceTypeId: number): Promise { await this.exists(technicianId); await this.serviceTypeService.exists(serviceTypeId); await this.technicianRepo.addServiceType(technicianId, serviceTypeId); } - async removeQualification( - technicianId: number, - serviceTypeId: number, - ): Promise { + async removeQualification(technicianId: number, serviceTypeId: number): Promise { await this.technicianRepo.removeServiceType(technicianId, serviceTypeId); } } diff --git a/src/modules/technician/tests/technician.service.spec.ts b/src/modules/technician/tests/technician.service.spec.ts index 2beddf7..b2c7019 100644 --- a/src/modules/technician/tests/technician.service.spec.ts +++ b/src/modules/technician/tests/technician.service.spec.ts @@ -18,6 +18,7 @@ type MockTechnicianRepository = { }; describe('TechnicianService', () => { + let moduleRef: TestingModule; let service: TechnicianService; let techRepo: MockTechnicianRepository; let dealershipService: DealershipService; @@ -39,7 +40,7 @@ describe('TechnicianService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ TechnicianService, { @@ -65,10 +66,15 @@ describe('TechnicianService', () => { ], }).compile(); - service = module.get(TechnicianService); - techRepo = module.get(TechnicianRepository); - dealershipService = module.get(DealershipService); - serviceTypeService = module.get(ServiceTypeService); + service = moduleRef.get(TechnicianService); + techRepo = moduleRef.get(TechnicianRepository); + dealershipService = moduleRef.get(DealershipService); + serviceTypeService = moduleRef.get(ServiceTypeService); + }); + + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); }); it('findAll returns empty list', async () => { @@ -121,9 +127,7 @@ describe('TechnicianService', () => { }); it('create throws NotFoundException when dealership missing', async () => { - (dealershipService.exists as jest.Mock).mockRejectedValue( - new NotFoundException(), - ); + (dealershipService.exists as jest.Mock).mockRejectedValue(new NotFoundException()); await expect(service.create(dto)).rejects.toBeInstanceOf(NotFoundException); }); @@ -152,11 +156,11 @@ describe('TechnicianService', () => { }); }); - it('findActive returns list', async () => { + it('findBy returns active technicians list', async () => { techRepo.find?.mockResolvedValue([fixture]); - expect(await service.findActive({ serviceTypeId: 9 })).toEqual([fixture]); + expect(await service.findBy({ serviceTypeId: 9 }, true)).toEqual([fixture]); expect(techRepo.find).toHaveBeenCalledWith({ - where: { serviceType: { id: 9 } }, + where: { dealershipId: undefined, serviceType: { id: 9 }, active: true }, relations: { dealership: true, serviceType: true }, }); }); @@ -174,9 +178,7 @@ describe('TechnicianService', () => { it('update throws NotFoundException when missing', async () => { techRepo.exists?.mockResolvedValue(false); - await expect(service.update(99, {})).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect(service.update(99, {})).rejects.toBeInstanceOf(NotFoundException); }); it('delete removes and returns entity', async () => { diff --git a/src/modules/vehicle/entities/vehicle.entity.ts b/src/modules/vehicle/entities/vehicle.entity.ts index 85b444b..562eacb 100644 --- a/src/modules/vehicle/entities/vehicle.entity.ts +++ b/src/modules/vehicle/entities/vehicle.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@src/database/entities/base.entity'; -import { Column, Entity, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Customer } from '../../customer/entities/customer.entity'; @Entity() +@Index('sch_vehicle_NC_customerId', ['customerId']) export class Vehicle extends BaseEntity { @Column({ type: 'nvarchar', length: 17, unique: true }) vin: string; @@ -17,7 +18,8 @@ export class Vehicle extends BaseEntity { year: number; @ManyToOne(() => Customer, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'customer_id' }) customer: Customer; - @Column({ type: 'int', unsigned: true }) + @Column({ name: 'customer_id', type: 'int', unsigned: true }) customerId: number; } diff --git a/src/modules/vehicle/tests/vehicle.service.spec.ts b/src/modules/vehicle/tests/vehicle.service.spec.ts index 74fe8ee..532a298 100644 --- a/src/modules/vehicle/tests/vehicle.service.spec.ts +++ b/src/modules/vehicle/tests/vehicle.service.spec.ts @@ -1,16 +1,14 @@ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { - MockRepository, - mockRepository, -} from '../../../common/testing/repository.mock'; +import { MockRepository, mockRepository } from '../../../common/testing/repository.mock'; import { VehicleService } from '../vehicle.service'; import { CreateVehicleDto } from '../dtos/create-vehicle.dto'; import { Vehicle } from '../entities/vehicle.entity'; import { CustomerService } from '../../customer/customer.service'; describe('VehicleService', () => { + let moduleRef: TestingModule; let service: VehicleService; let repo: MockRepository; let customerService: CustomerService; @@ -32,7 +30,7 @@ describe('VehicleService', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ providers: [ VehicleService, { @@ -46,9 +44,14 @@ describe('VehicleService', () => { ], }).compile(); - service = module.get(VehicleService); - repo = module.get(getRepositoryToken(Vehicle)); - customerService = module.get(CustomerService); + service = moduleRef.get(VehicleService); + repo = moduleRef.get(getRepositoryToken(Vehicle)); + customerService = moduleRef.get(CustomerService); + }); + + afterEach(async () => { + await moduleRef.close(); + jest.clearAllMocks(); }); it('findAll returns empty list', async () => { @@ -110,9 +113,7 @@ describe('VehicleService', () => { it('update throws NotFoundException when missing', async () => { repo.exists?.mockResolvedValue(false); - await expect(service.update(99, {})).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect(service.update(99, {})).rejects.toBeInstanceOf(NotFoundException); }); it('delete removes entity', async () => { diff --git a/src/shared/utils/common.helper.ts b/src/shared/utils/common.helper.ts new file mode 100644 index 0000000..9c8cd7e --- /dev/null +++ b/src/shared/utils/common.helper.ts @@ -0,0 +1,8 @@ +export function shuffle(arr: T[]): T[] { + const items = [...arr]; + for (let i = items.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + return items; +} diff --git a/src/shared/utils/date.helper.ts b/src/shared/utils/date.helper.ts index 3aafe79..050ae2d 100644 --- a/src/shared/utils/date.helper.ts +++ b/src/shared/utils/date.helper.ts @@ -5,10 +5,16 @@ import timezone from 'dayjs/plugin/timezone'; dayjs.extend(utc); dayjs.extend(timezone); -export function toUtc(date: string, time: string, timezone: string): Date { - return dayjs.tz(`${date} ${time}`, timezone).utc().toDate(); +export function toUtc(date: string, time: string, timezone: string): Date; +export function toUtc(dateTime: string | Date): Date; +export function toUtc(date: string | Date, time?: string, timezone?: string): Date { + if (typeof date === 'string' && time && timezone) + return dayjs.tz(`${date} ${time}`, 'YYYY-MM-DD HH:mm:ss', timezone).utc().toDate(); + else return dayjs(date).utc().toDate(); } export function toLocal(date: Date, timezone: string): Date { return dayjs(date).tz(timezone).toDate(); } + +export default dayjs; diff --git a/test/appointment.e2e-spec.ts b/test/appointment.e2e-spec.ts new file mode 100644 index 0000000..b1aadcd --- /dev/null +++ b/test/appointment.e2e-spec.ts @@ -0,0 +1,197 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { DataSource, Repository } from 'typeorm'; +import { initializeTransactionalContext, StorageDriver } from 'typeorm-transactional'; +import { AppModule } from '../src/app.module'; +import { RuntimeConfig, SwaggerConfig } from '../src/configs/config.interface'; +import { EAppointmentStatus } from '../src/modules/appointment/constants/appointment.constant'; +import { Appointment } from '../src/modules/appointment/entities/appointment.entity'; +import { ResourceReservation } from '../src/modules/appointment/entities/resource-reservation.entity'; +import { Customer } from '../src/modules/customer/entities/customer.entity'; +import { Dealership } from '../src/modules/dealership/entities/dealership.entity'; +import { ServiceBay } from '../src/modules/service-bay/entities/service-bay.entity'; +import { ServiceType } from '../src/modules/service-type/entities/service-type.entity'; +import { Technician } from '../src/modules/technician/entities/technician.entity'; +import { Vehicle } from '../src/modules/vehicle/entities/vehicle.entity'; + +type SeededFixture = { + dealership: Dealership; + customer: Customer; + vehicle: Vehicle; + serviceType: ServiceType; + serviceBay: ServiceBay; + technician: Technician; +}; + +describe('Appointment booking (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + let moduleRef: TestingModule; + let appointmentRepo: Repository; + let reservationRepo: Repository; + let dealershipRepo: Repository; + let customerRepo: Repository; + let vehicleRepo: Repository; + let serviceTypeRepo: Repository; + let serviceBayRepo: Repository; + let technicianRepo: Repository; + + beforeAll(async () => { + initializeTransactionalContext({ storageDriver: StorageDriver.AUTO }); + + moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + validationError: { + target: false, + value: false, + }, + transformOptions: { enableImplicitConversion: true }, + stopAtFirstError: true, + }), + ); + + const configService = app.get(ConfigService); + const swaggerConfig = configService.getOrThrow('swagger'); + expect(swaggerConfig.enabled).toBeDefined(); + + await app.init(); + + dataSource = app.get(DataSource); + appointmentRepo = dataSource.getRepository(Appointment); + reservationRepo = dataSource.getRepository(ResourceReservation); + dealershipRepo = dataSource.getRepository(Dealership); + customerRepo = dataSource.getRepository(Customer); + vehicleRepo = dataSource.getRepository(Vehicle); + serviceTypeRepo = dataSource.getRepository(ServiceType); + serviceBayRepo = dataSource.getRepository(ServiceBay); + technicianRepo = dataSource.getRepository(Technician); + }); + + beforeEach(async () => { + await dataSource.synchronize(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await app.close(); + await moduleRef.close(); + }); + + it('POST /api/appointments books an appointment and reserves both resources', async () => { + const fx = await seedBookingFixture(); + + const response = await request(app.getHttpServer()) + .post('/api/appointments') + .send({ + dealershipId: fx.dealership.id, + serviceBayId: fx.serviceBay.id, + vehicleId: fx.vehicle.id, + serviceTypeId: fx.serviceType.id, + startAt: '2026-07-20T09:00:00.000Z', + }) + .expect(201); + + expect(response.body.isSuccess).toBe(true); + expect(response.body.data).toMatchObject({ + dealershipId: fx.dealership.id, + customerId: fx.customer.id, + vehicleId: fx.vehicle.id, + serviceBayId: fx.serviceBay.id, + serviceTypeId: fx.serviceType.id, + technicianId: fx.technician.id, + startAt: '2026-07-20T09:00:00.000Z', + endAt: '2026-07-20T09:30:00.000Z', + status: EAppointmentStatus.CONFIRMED, + }); + + const appointmentId = response.body.data.id as number; + const saved = await appointmentRepo.findOne({ where: { id: appointmentId } }); + const reservations = await reservationRepo.find({ + where: { appointmentId }, + order: { slotStart: 'ASC', resourceType: 'ASC' }, + }); + + expect(saved).not.toBeNull(); + expect(reservations).toHaveLength(4); + expect(reservations.map((reservation) => reservation.slotStart.toISOString())).toEqual([ + '2026-07-20T09:00:00.000Z', + '2026-07-20T09:00:00.000Z', + '2026-07-20T09:15:00.000Z', + '2026-07-20T09:15:00.000Z', + ]); + expect(await reservationRepo.count()).toBe(4); + }); + + async function seedBookingFixture(): Promise { + const dealership = await dealershipRepo.save({ + name: 'Downtown Service', + address: '123 Main St', + city: 'Seoul', + country: 'KR', + timezone: 'UTC', + openTime: '08:00:00', + closeTime: '17:00:00', + }); + + const customer = await customerRepo.save({ + name: 'Jane Driver', + email: 'jane.driver@example.com', + phone: '010-1234-5678', + }); + + const vehicle = await vehicleRepo.save({ + vin: '1HGCM82633A123456', + make: 'Honda', + model: 'Accord', + year: 2024, + customerId: customer.id, + }); + + const serviceType = await serviceTypeRepo.save({ + code: 'OIL-30', + name: 'Oil Change', + durationMinutes: 30, + }); + + const serviceBay = await serviceBayRepo.save({ + name: 'Bay 1', + active: true, + dealershipId: dealership.id, + }); + + const technician = await technicianRepo.save({ + name: 'Tech 1', + active: true, + dealershipId: dealership.id, + }); + + await dataSource + .createQueryBuilder() + .relation(Technician, 'serviceType') + .of(technician.id) + .add(serviceType.id); + + return { + dealership, + customer, + vehicle, + serviceType, + serviceBay, + technician, + }; + } +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..7f24e0a 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -3,6 +3,9 @@ "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "moduleNameMapper": { + "^@src/(.*)$": "/../src/$1" + }, "transform": { "^.+\\.(t|j)s$": "ts-jest" }