Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 8 additions & 11 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`:
Expand All @@ -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)_

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the malformed Task 11 status marker.

[[✓] has an extra opening bracket and does not match the documented status format. Change it to [✓].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TASKS.md` at line 150, Update the Task 11 status marker in TASKS.md from the
malformed [[✓] form to the documented [✓] format, preserving the existing
dependency text.


- `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show targeted sections with line numbers
sed -n '145,205p' TASKS.md | cat -n

# Search for relevant references in TASKS.md
rg -n "typeorm-transaction|queryRunner|transaction" TASKS.md

Repository: hytnht/appointment-scheduler

Length of output: 3416


Use one authoritative transaction mechanism in TASKS.md

Task 11 still says to begin the booking transaction with typeorm-transaction, but the locked decisions specify TypeORM queryRunner for the atomic booking txn. Align the task text with the locked decision so the implementation doesn’t drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TASKS.md` at line 157, Update Task 11 in TASKS.md to replace the
typeorm-transaction instruction with the locked TypeORM queryRunner mechanism
for beginning the atomic booking transaction. Keep the surrounding task steps
unchanged.

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
Expand All @@ -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
Comment thread
hytnht marked this conversation as resolved.

**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

---

Expand All @@ -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
21 changes: 6 additions & 15 deletions src/common/filters/exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -56,8 +52,7 @@ export class CustomExceptionFilter implements ExceptionFilter {
}

private handleTypeOrmException(exception = {} as TypeORMError) {
const { driverError: { code, errno } = {} } =
exception as QueryFailedError<MysqlError>;
const { driverError: { code, errno } = {} } = exception as QueryFailedError<MysqlError>;
switch (errno ?? code) {
case 404:
return {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/configs/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default () => ({
keepConnectionAlive: true,
extra: { connectionLimit: 10 },
timezone: 'Z',
logging: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the exact context around the cited line.
git ls-files src/configs/app.config.ts
wc -l src/configs/app.config.ts
cat -n src/configs/app.config.ts | sed -n '1,220p'

# Search for other TypeORM / logging configuration that may override or contextualize this setting.
rg -n "logging\s*:|TypeOrmModule|DataSource|typeorm" src . --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: hytnht/appointment-scheduler

Length of output: 16060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the config interface and database wiring.
cat -n src/configs/config.interface.ts
printf '\n---\n'
cat -n src/database/database.module.ts
printf '\n---\n'
cat -n src/app.module.ts
printf '\n---\n'
cat -n src/main.ts

# Locate Swagger setup and how the config is consumed.
rg -n "SwaggerModule|createDocument|swagger\.|tagsSorter|operationsSorter|SwaggerCustomOptions|swaggerOptions|options:" src test

Repository: hytnht/appointment-scheduler

Length of output: 6753


Disable TypeORM logging in production

logging: true turns on query/error logging in every environment, including production. Make it environment-aware or configurable so SQL details and log volume don’t spill into prod logs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/configs/app.config.ts` at line 15, Update the logging setting in the
application configuration so TypeORM logging is disabled in production while
remaining enabled in appropriate non-production environments. Use the existing
environment/configuration mechanism rather than hardcoding logging: true for
every deployment.

Source: MCP tools

replication: {
defaultMode: 'master',
restoreNodeTimeout: 3000,
Expand Down Expand Up @@ -39,5 +40,9 @@ export default () => ({
description: 'Appointment Scheduler Service API documentation',
version: '1.0',
path: 'api-docs',
swaggerOptions: {
tagsSorter: 'alpha',
operationsSorter: 'method',
},
},
});
2 changes: 2 additions & 0 deletions src/configs/config.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SwaggerCustomOptions } from '@nestjs/swagger';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';

export interface SwaggerConfig {
Expand All @@ -6,6 +7,7 @@ export interface SwaggerConfig {
description: string;
version: string;
path: string;
swaggerOptions: SwaggerCustomOptions;
}

export interface RuntimeConfig {
Expand Down
3 changes: 1 addition & 2 deletions src/database/database.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { RuntimeConfig } from '@src/configs/config.interface';
imports: [
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService<RuntimeConfig>) => {
const database =
configService.getOrThrow<TypeOrmModuleOptions>('database');
const database = configService.getOrThrow<TypeOrmModuleOptions>('database');
return database;
},
dataSourceFactory: async (options) => {
Expand Down
10 changes: 7 additions & 3 deletions src/database/entities/base.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 0 additions & 9 deletions src/database/entities/timestamp.entity.ts

This file was deleted.

19 changes: 9 additions & 10 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<NestExpressApplication>(AppModule, {
logger: loggerConfig,
});

Expand All @@ -39,14 +37,15 @@ async function bootstrap() {
const configService = app.get(ConfigService);

const swaggerConfig = configService.getOrThrow<SwaggerConfig>('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<number>('port');
Expand Down
30 changes: 27 additions & 3 deletions src/modules/appointment/appointment.controller.ts
Original file line number Diff line number Diff line change
@@ -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<AppointmentSearchResult> {
return this.appointmentService.search(query);
}

@Get('availability/:id')
getAvailability(
@Param('id') dealershipId: number,
@Query() { serviceTypeId, date }: GetAvailabilityDto,
): Promise<string[]> {
return this.appointmentService.getAvailability(dealershipId, serviceTypeId, date);
}
Comment thread
hytnht marked this conversation as resolved.

@Post()
create(@Body() dto: CreateAppointmentDto): Promise<Appointment> {
return this.appointmentService.createAppointment(dto);
}

@Get(':id')
findOne(@Param('id') id: number): Promise<Appointment> {
return this.appointmentService.findOne(id);
}

@Patch(':id')
patch(@Param('id') id: number): Promise<Appointment> {
return this.appointmentService.cancelAppointment(id);
}
}
77 changes: 62 additions & 15 deletions src/modules/appointment/appointment.helper.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -27,28 +32,39 @@ 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<Dealership, 'openTime' | 'closeTime' | 'timezone'>,
date: string,
durationMinutes: number,
slotSizeMins = SLOT_SIZE_MINUTES,
): Date[] {
const { openTime, closeTime, timezone } = dealershipTime;
const openTimeUtc = toUtc(date, openTime, timezone).getTime();
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<string>,
type: EResourceType,
resourceId: number,
): boolean => {
return slots.every(
(slotStart) => !blocked.has(getReserveKey({ resourceType: type, resourceId, slotStart })),
);
};
export const hasFreeResource =
(
blocked: Set<string>,
Expand All @@ -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<EResourceType, number[]>,
slots: Date[],
blocked: Set<string>,
) => {
const result = {} as Record<EResourceType, number[]>;
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<EResourceType, number[]>,
reservations: ResourceReservation[],
topK = TOP_K_RESOURCES,
) {
const counts = {} as Record<EResourceType, Record<number, number>>;
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<EResourceType, number[]>;
const remaining = {} as Record<EResourceType, number[]>;
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 };
}
Loading