# Backend Standard Business Skeleton Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Initialize `backend/` as a runnable NestJS service with `/api/v1` routing, unified response envelope, Swagger, Prisma/MySQL, Redis, and zero-mock business module skeletons aligned with the current frontend API contract. **Architecture:** Start from a standard NestJS application, then add cross-cutting infrastructure in `src/common`, `src/config`, `src/prisma`, and `src/redis`. Build thin business modules for `health`, `auth`, `customers`, `templates`, and `simulations`, where unfinished endpoints fail explicitly through a unified error contract instead of returning fake data. **Tech Stack:** NestJS, TypeScript, Prisma, MySQL, Redis, class-validator, class-transformer, @nestjs/config, @nestjs/swagger, @nestjs/jwt, Jest, Supertest ## Global Constraints - API prefix must be exactly `/api/v1`. - Success and error responses must follow exactly `{ ok, data, error }`. - Keep zero-mock behavior: unfinished features must fail explicitly and must not fabricate business data. - Default auth header name must remain `Authorization`. - Swagger must be enabled for local API discovery. - Environment variables must include `PORT`, `APP_BASE_URL`, `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `JWT_EXPIRES_IN`, `CORS_ORIGIN`, and `NODE_ENV`. - Prisma schema must cover `hairstylist_user`, `customer_treatment_record`, `ai_hair_simulation`, `ai_hair_simulation_task`, `hair_template`, and `hair_template_make_task`. - Initialization scope stops at project skeletons, infrastructure wiring, placeholder endpoints, docs, and baseline tests. --- ### Task 1: Scaffold The NestJS Backend Project **Files:** - Create: `backend/package.json` - Create: `backend/tsconfig.json` - Create: `backend/tsconfig.build.json` - Create: `backend/nest-cli.json` - Create: `backend/.gitignore` - Create: `backend/.prettierrc` - Create: `backend/eslint.config.mjs` - Create: `backend/src/main.ts` - Create: `backend/src/app.module.ts` - Create: `backend/src/app.controller.ts` - Create: `backend/src/app.service.ts` - Create: `backend/test/jest-e2e.json` - Create: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: none - Produces: a compilable NestJS app entry with `bootstrap(): Promise` in `backend/src/main.ts` - [ ] **Step 1: Scaffold the NestJS project files** ```json { "name": "aitori-backend", "version": "0.1.0", "private": true, "scripts": { "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main.js", "lint": "eslint \"{src,test}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", "test:e2e": "jest --config ./test/jest-e2e.json", "prisma:generate": "prisma generate", "prisma:validate": "prisma validate" }, "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, "devDependencies": { "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.0.0", "@types/supertest": "^6.0.2", "eslint": "^9.0.0", "jest": "^30.0.0", "prettier": "^3.3.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.6.3", "typescript-eslint": "^8.0.0" } } ``` - [ ] **Step 2: Write the initial e2e test before business infrastructure** ```ts import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { AppModule } from '../src/app.module'; describe('App (e2e)', () => { let app: INestApplication; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleRef.createNestApplication(); await app.init(); }); afterAll(async () => { await app.close(); }); it('/ (GET)', () => { return request(app.getHttpServer()).get('/').expect(200).expect('Aitori backend bootstrap ready'); }); }); ``` - [ ] **Step 3: Run the bootstrap test to verify the empty scaffold fails before code exists** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm install && npm run test:e2e -- --runInBand` Expected: FAIL with missing application files such as `Cannot find module '../src/app.module'` - [ ] **Step 4: Add the minimal Nest bootstrap implementation** ```ts // backend/src/app.controller.ts import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getRoot(): string { return this.appService.getRoot(); } } ``` ```ts // backend/src/app.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getRoot(): string { return 'Aitori backend bootstrap ready'; } } ``` ```ts // backend/src/app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ controllers: [AppController], providers: [AppService], }) export class AppModule {} ``` ```ts // backend/src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); await app.listen(3000); } void bootstrap(); ``` - [ ] **Step 5: Run tests and build to verify the basic scaffold passes** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand && npm run build` Expected: - `PASS test/app.e2e-spec.ts` - `webpack compiled` or `nest build` success with exit code `0` - [ ] **Step 6: Commit the scaffold task** ```bash git add backend/package.json backend/tsconfig.json backend/tsconfig.build.json backend/nest-cli.json backend/.gitignore backend/.prettierrc backend/eslint.config.mjs backend/src/main.ts backend/src/app.module.ts backend/src/app.controller.ts backend/src/app.service.ts backend/test/jest-e2e.json backend/test/app.e2e-spec.ts git commit -m "feat: initialize nest backend scaffold" ``` ### Task 2: Add Config, Unified Responses, Swagger, And Health **Files:** - Modify: `backend/src/main.ts` - Modify: `backend/src/app.module.ts` - Create: `backend/src/config/app.config.ts` - Create: `backend/src/config/env.schema.ts` - Create: `backend/src/common/response/api-envelope.ts` - Create: `backend/src/common/interceptors/api-response.interceptor.ts` - Create: `backend/src/common/filters/http-exception.filter.ts` - Create: `backend/src/modules/health/health.module.ts` - Create: `backend/src/modules/health/health.controller.ts` - Create: `backend/src/modules/health/health.service.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: `bootstrap(): Promise` from `backend/src/main.ts` - Produces: - `ApiEnvelope` type in `backend/src/common/response/api-envelope.ts` - `ApiResponseInterceptor` in `backend/src/common/interceptors/api-response.interceptor.ts` - `HttpExceptionFilter` in `backend/src/common/filters/http-exception.filter.ts` - `HealthService.getStatus(): { status: 'ok'; timestamp: string }` - [ ] **Step 1: Replace the root bootstrap test with health-envelope tests** ```ts it('/api/v1/health (GET)', async () => { const response = await request(app.getHttpServer()).get('/api/v1/health').expect(200); expect(response.body).toEqual({ ok: true, data: { status: 'ok', timestamp: expect.any(String), }, error: null, }); }); it('/api/v1/unknown (GET)', async () => { const response = await request(app.getHttpServer()).get('/api/v1/unknown').expect(404); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_404', message: expect.any(String), }, }); }); ``` - [ ] **Step 2: Run the updated tests to verify the new requirements fail** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: FAIL because `/api/v1/health` is missing and current responses are not wrapped in `{ ok, data, error }` - [ ] **Step 3: Implement config loading, global prefix, envelope interceptor, exception filter, Swagger, and health module** ```ts // backend/src/config/app.config.ts export default () => ({ port: Number(process.env.PORT ?? 3000), appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000', corsOrigin: process.env.CORS_ORIGIN ?? 'http://127.0.0.1:5173', jwtSecret: process.env.JWT_SECRET ?? 'replace-me', jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', }); ``` ```ts // backend/src/config/env.schema.ts import { z } from 'zod'; export const envSchema = z.object({ PORT: z.coerce.number().default(3000), APP_BASE_URL: z.string().url(), DATABASE_URL: z.string().min(1), REDIS_URL: z.string().min(1), JWT_SECRET: z.string().min(1), JWT_EXPIRES_IN: z.string().min(1), CORS_ORIGIN: z.string().min(1), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), }); ``` ```ts // backend/src/common/response/api-envelope.ts export type ApiEnvelope = { ok: boolean; data: T | null; error: { code: string; message: string } | null; }; ``` ```ts // backend/src/common/interceptors/api-response.interceptor.ts import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ApiEnvelope } from '../response/api-envelope'; @Injectable() export class ApiResponseInterceptor implements NestInterceptor> { intercept(_context: ExecutionContext, next: CallHandler): Observable> { return next.handle().pipe( map((data) => ({ ok: true, data, error: null, })), ); } } ``` ```ts // backend/src/common/filters/http-exception.filter.ts import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const response = host.switchToHttp().getResponse(); if (exception instanceof HttpException) { const status = exception.getStatus(); const payload = exception.getResponse(); const message = typeof payload === 'object' && payload && 'message' in payload ? Array.isArray((payload as { message: string | string[] }).message) ? (payload as { message: string[] }).message.join(', ') : (payload as { message: string }).message : exception.message; response.status(status).json({ ok: false, data: null, error: { code: `HTTP_${status}`, message, }, }); return; } response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ ok: false, data: null, error: { code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error', }, }); } } ``` ```ts // backend/src/modules/health/health.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class HealthService { getStatus(): { status: 'ok'; timestamp: string } { return { status: 'ok', timestamp: new Date().toISOString(), }; } } ``` ```ts // backend/src/modules/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthService } from './health.service'; @Controller('health') export class HealthController { constructor(private readonly healthService: HealthService) {} @Get() getHealth() { return this.healthService.getStatus(); } } ``` ```ts // backend/src/main.ts import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ApiResponseInterceptor } from './common/interceptors/api-response.interceptor'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })); app.useGlobalInterceptors(new ApiResponseInterceptor()); app.useGlobalFilters(new HttpExceptionFilter()); app.enableCors(); const document = SwaggerModule.createDocument( app, new DocumentBuilder().setTitle('Aitori Backend').setVersion('0.1.0').addBearerAuth().build(), ); SwaggerModule.setup('api/docs', app, document); await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000); } void bootstrap(); ``` - [ ] **Step 4: Register the config and health module in the application** ```ts // backend/src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import appConfig from './config/app.config'; import { HealthModule } from './modules/health/health.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true, load: [appConfig], }), HealthModule, ], }) export class AppModule {} ``` ```ts // backend/src/modules/health/health.module.ts import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; import { HealthService } from './health.service'; @Module({ controllers: [HealthController], providers: [HealthService], }) export class HealthModule {} ``` - [ ] **Step 5: Run tests and a local smoke check** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand && npm run start:dev` Expected: - `PASS test/app.e2e-spec.ts` - local `GET http://localhost:3000/api/v1/health` returns wrapped health data - Swagger opens at `http://localhost:3000/api/docs` - [ ] **Step 6: Commit the infrastructure task** ```bash git add backend/src/main.ts backend/src/app.module.ts backend/src/config/app.config.ts backend/src/config/env.schema.ts backend/src/common/response/api-envelope.ts backend/src/common/interceptors/api-response.interceptor.ts backend/src/common/filters/http-exception.filter.ts backend/src/modules/health/health.module.ts backend/src/modules/health/health.controller.ts backend/src/modules/health/health.service.ts backend/test/app.e2e-spec.ts git commit -m "feat: add config envelope and health module" ``` ### Task 3: Add Prisma, Redis, And Core Environment Files **Files:** - Modify: `backend/package.json` - Create: `backend/.env.example` - Create: `backend/prisma/schema.prisma` - Create: `backend/src/prisma/prisma.module.ts` - Create: `backend/src/prisma/prisma.service.ts` - Create: `backend/src/redis/redis.module.ts` - Create: `backend/src/redis/redis.service.ts` - Modify: `backend/src/app.module.ts` - Modify: `backend/src/modules/health/health.service.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: - `HealthService.getStatus()` - `AppModule` - Produces: - `PrismaService` with `onModuleInit(): Promise` and `enableShutdownHooks(app: INestApplication): Promise` - `RedisService.ping(): Promise<'PONG' | 'UNAVAILABLE'>` - health payload shape `{ status: 'ok'; timestamp: string; services: { database: string; redis: string } }` - [ ] **Step 1: Add e2e expectation for service summaries** ```ts expect(response.body).toEqual({ ok: true, data: { status: 'ok', timestamp: expect.any(String), services: { database: expect.any(String), redis: expect.any(String), }, }, error: null, }); ``` - [ ] **Step 2: Run the health test to verify the service summaries are still missing** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: FAIL because `services.database` and `services.redis` are not returned yet - [ ] **Step 3: Add Prisma, Redis, environment example, and initial schema** ```env PORT=3000 APP_BASE_URL=http://localhost:3000 DATABASE_URL=mysql://root:root@localhost:3306/aitori_backend REDIS_URL=redis://localhost:6379 JWT_SECRET=replace-me JWT_EXPIRES_IN=7d CORS_ORIGIN=http://127.0.0.1:5173 NODE_ENV=development ``` ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } enum SimulationTaskStatus { PROCESSING SUCCESSES FAILED } enum TemplateTaskStatus { WAIT PROCESSING STEP1_SUCCESS STEP2_SUCCESS FAILED } model HairstylistUser { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt username String @db.VarChar(50) email String @db.VarChar(100) password String @db.VarChar(100) createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("hairstylist_user") } model CustomerTreatmentRecord { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt customerId BigInt @map("customer_id") @db.UnsignedBigInt hairstylistId BigInt @map("hairstylist_id") @db.UnsignedBigInt treatmentTime DateTime @map("treatment_time") treatmentTitle String @map("treatment_title") @db.VarChar(200) treatmentDetail String? @map("treatment_detail") @db.Text createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("customer_treatment_record") } model AiHairSimulation { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt customerId BigInt? @map("customer_id") @db.UnsignedBigInt hairstylistId BigInt @map("hairstylist_id") @db.UnsignedBigInt originImage String @map("origin_image") @db.VarChar(500) simulateRemark String? @map("simulate_remark") @db.Text createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("ai_hair_simulation") } model AiHairSimulationTask { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt simulationId BigInt @map("simulation_id") @db.UnsignedBigInt templateId String @map("template_id") @db.VarChar(100) customerId BigInt? @map("customer_id") @db.UnsignedBigInt taskId String @map("task_id") @db.VarChar(200) taskStatus SimulationTaskStatus @map("task_status") resultImages String? @map("result_images") @db.Text taskStartTime DateTime @map("task_start_time") taskFinishTime DateTime? @map("task_finish_time") errorMsg String? @map("error_msg") @db.Text createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("ai_hair_simulation_task") } model HairTemplate { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt templateName String @map("template_name") @db.VarChar(100) coverUrl String? @map("cover_url") @db.VarChar(500) status String @db.VarChar(32) category String @db.VarChar(32) tags String? @db.VarChar(500) description String? @db.Text modelUrl String? @map("model_url") @db.VarChar(500) clothesUrl String? @map("clothes_url") @db.VarChar(500) backgroundUrl String? @map("background_url") @db.VarChar(500) refHairUrl String? @map("ref_hair_url") @db.VarChar(500) createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("hair_template") } model HairTemplateMakeTask { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt totalTaskId String @map("total_task_id") @db.VarChar(200) step1TaskId String? @map("step1_task_id") @db.VarChar(200) step2TaskId String? @map("step2_task_id") @db.VarChar(200) taskStatus TemplateTaskStatus @map("task_status") templateId BigInt? @map("template_id") @db.UnsignedBigInt step1Resource String? @map("step1_resource") @db.Text step2Resource String? @map("step2_resource") @db.Text refHairUrl String? @map("ref_hair_url") @db.VarChar(500) modelUrl String? @map("model_url") @db.VarChar(500) clothesUrl String? @map("clothes_url") @db.VarChar(500) backgroundUrl String? @map("background_url") @db.VarChar(500) step1ResultImg String? @map("step1_result_img") @db.VarChar(500) step2ResultImg String? @map("step2_result_img") @db.VarChar(500) createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@map("hair_template_make_task") } ``` ```ts // backend/src/prisma/prisma.service.ts import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit(): Promise { await this.$connect(); } async enableShutdownHooks(app: INestApplication): Promise { this.$on('beforeExit', async () => { await app.close(); }); } } ``` ```ts // backend/src/redis/redis.service.ts import { Injectable } from '@nestjs/common'; import Redis from 'ioredis'; @Injectable() export class RedisService { private readonly client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { lazyConnect: true, maxRetriesPerRequest: 1, }); async ping(): Promise<'PONG' | 'UNAVAILABLE'> { try { if (this.client.status === 'wait') { await this.client.connect(); } return await this.client.ping(); } catch { return 'UNAVAILABLE'; } } } ``` - [ ] **Step 4: Wire Prisma and Redis into the app and health service** ```ts // backend/src/modules/health/health.service.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { RedisService } from '../../redis/redis.service'; @Injectable() export class HealthService { constructor( private readonly prismaService: PrismaService, private readonly redisService: RedisService, ) {} async getStatus(): Promise<{ status: 'ok'; timestamp: string; services: { database: string; redis: string }; }> { const database = await this.prismaService .$queryRaw`SELECT 1` .then(() => 'up') .catch(() => 'down'); const redis = await this.redisService.ping().then((value) => (value === 'PONG' ? 'up' : 'down')); return { status: 'ok', timestamp: new Date().toISOString(), services: { database, redis }, }; } } ``` ```ts // backend/src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { HealthModule } from './modules/health/health.module'; import { PrismaModule } from './prisma/prisma.module'; import { RedisModule } from './redis/redis.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true }), PrismaModule, RedisModule, HealthModule, ], }) export class AppModule {} ``` - [ ] **Step 5: Generate the Prisma client and rerun tests** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm install @prisma/client prisma ioredis @nestjs/swagger swagger-ui-express class-validator class-transformer && npm run prisma:generate && npm run test:e2e -- --runInBand` Expected: - Prisma client generation succeeds - `PASS test/app.e2e-spec.ts` - [ ] **Step 6: Commit the data infrastructure task** ```bash git add backend/package.json backend/.env.example backend/prisma/schema.prisma backend/src/prisma/prisma.module.ts backend/src/prisma/prisma.service.ts backend/src/redis/redis.module.ts backend/src/redis/redis.service.ts backend/src/app.module.ts backend/src/modules/health/health.service.ts backend/test/app.e2e-spec.ts git commit -m "feat: add prisma redis and env infrastructure" ``` ### Task 4: Build The Auth Module Skeleton With Explicit Failure Semantics **Files:** - Modify: `backend/package.json` - Create: `backend/src/modules/auth/auth.module.ts` - Create: `backend/src/modules/auth/auth.controller.ts` - Create: `backend/src/modules/auth/auth.service.ts` - Create: `backend/src/modules/auth/dto/stylist-login.dto.ts` - Create: `backend/src/modules/auth/dto/login-result.dto.ts` - Create: `backend/src/modules/auth/guards/jwt-auth.guard.ts` - Modify: `backend/src/app.module.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: - `ApiEnvelope` response wrapping - global `ValidationPipe` - Produces: - `POST /api/v1/auth/stylist/login` - `POST /api/v1/auth/logout` - `AuthService.login(input: StylistLoginDto): Promise` - `AuthService.logout(): Promise<{ success: true }>` - [ ] **Step 1: Add the failing auth e2e tests** ```ts it('/api/v1/auth/stylist/login (POST) validates request body', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/auth/stylist/login') .send({ email: 'bad-email', password: '' }) .expect(400); expect(response.body.ok).toBe(false); expect(response.body.data).toBeNull(); }); it('/api/v1/auth/stylist/login (POST) fails explicitly when implementation is not ready', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/auth/stylist/login') .send({ email: 'stylist@example.com', password: 'Passw0rd!' }) .expect(501); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_501', message: 'Stylist login is not implemented yet', }, }); }); ``` - [ ] **Step 2: Run auth tests to verify the routes do not exist yet** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: FAIL with 404 or missing route handlers for `/api/v1/auth/stylist/login` - [ ] **Step 3: Add auth DTOs, controller, service, and guard shell** ```ts // backend/src/modules/auth/dto/stylist-login.dto.ts import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; export class StylistLoginDto { @IsEmail() email!: string; @IsString() @MinLength(8) password!: string; @IsOptional() @IsBoolean() remember?: boolean; } ``` ```ts // backend/src/modules/auth/dto/login-result.dto.ts export class LoginResultDto { token!: string; tokenName!: string; stylistId!: number; name!: string; email!: string; } ``` ```ts // backend/src/modules/auth/auth.service.ts import { Injectable, NotImplementedException } from '@nestjs/common'; import { StylistLoginDto } from './dto/stylist-login.dto'; import { LoginResultDto } from './dto/login-result.dto'; @Injectable() export class AuthService { async login(_input: StylistLoginDto): Promise { throw new NotImplementedException('Stylist login is not implemented yet'); } async logout(): Promise<{ success: true }> { return { success: true }; } } ``` ```ts // backend/src/modules/auth/auth.controller.ts import { Body, Controller, Post } from '@nestjs/common'; import { AuthService } from './auth.service'; import { StylistLoginDto } from './dto/stylist-login.dto'; @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('stylist/login') login(@Body() body: StylistLoginDto) { return this.authService.login(body); } @Post('logout') logout() { return this.authService.logout(); } } ``` ```ts // backend/src/modules/auth/guards/jwt-auth.guard.ts import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; @Injectable() export class JwtAuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest<{ headers: Record }>(); const authorization = request.headers.authorization; if (!authorization) { throw new UnauthorizedException('Missing Authorization header'); } return true; } } ``` - [ ] **Step 4: Register auth in the app and validate the explicit-failure contract** ```ts // backend/src/modules/auth/auth.module.ts import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @Module({ controllers: [AuthController], providers: [AuthService], exports: [AuthService], }) export class AuthModule {} ``` ```ts // backend/src/app.module.ts import { AuthModule } from './modules/auth/auth.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true }), PrismaModule, RedisModule, HealthModule, AuthModule, ], }) export class AppModule {} ``` - [ ] **Step 5: Rerun e2e tests** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: - invalid login body returns 400 envelope - valid login body returns 501 envelope - existing health tests still pass - [ ] **Step 6: Commit the auth skeleton task** ```bash git add backend/package.json backend/src/modules/auth/auth.module.ts backend/src/modules/auth/auth.controller.ts backend/src/modules/auth/auth.service.ts backend/src/modules/auth/dto/stylist-login.dto.ts backend/src/modules/auth/dto/login-result.dto.ts backend/src/modules/auth/guards/jwt-auth.guard.ts backend/src/app.module.ts backend/test/app.e2e-spec.ts git commit -m "feat: add auth module skeleton" ``` ### Task 5: Build Customers, Templates, And Simulations Skeleton Modules **Files:** - Create: `backend/src/common/errors/not-ready.exception.ts` - Create: `backend/src/modules/customers/customers.module.ts` - Create: `backend/src/modules/customers/customers.controller.ts` - Create: `backend/src/modules/customers/customers.service.ts` - Create: `backend/src/modules/customers/dto/list-customers-query.dto.ts` - Create: `backend/src/modules/customers/dto/customer-upsert.dto.ts` - Create: `backend/src/modules/templates/templates.module.ts` - Create: `backend/src/modules/templates/templates.controller.ts` - Create: `backend/src/modules/templates/templates.service.ts` - Create: `backend/src/modules/templates/dto/list-templates-query.dto.ts` - Create: `backend/src/modules/templates/dto/create-template.dto.ts` - Create: `backend/src/modules/simulations/simulations.module.ts` - Create: `backend/src/modules/simulations/simulations.controller.ts` - Create: `backend/src/modules/simulations/simulations.service.ts` - Create: `backend/src/modules/simulations/dto/create-simulation.dto.ts` - Modify: `backend/src/app.module.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: - `ApiEnvelope` - zero-mock rule from Global Constraints - Produces: - `CustomersService.list(query: ListCustomersQueryDto): Promise<{ items: never[]; total: number }>` - `TemplatesService.list(query: ListTemplatesQueryDto): Promise<{ items: never[]; total: number }>` - `SimulationsService.create(input: CreateSimulationDto): Promise` - explicit `NotReadyException` for unfinished business routes - [ ] **Step 1: Add failing e2e tests for the skeleton business routes** ```ts it('/api/v1/customers (GET) fails explicitly instead of returning mock data', async () => { const response = await request(app.getHttpServer()).get('/api/v1/customers').expect(501); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_501', message: 'Customers list is not implemented yet', }, }); }); it('/api/v1/templates (GET) fails explicitly instead of returning mock data', async () => { const response = await request(app.getHttpServer()).get('/api/v1/templates').expect(501); expect(response.body.error.message).toBe('Templates list is not implemented yet'); }); it('/api/v1/simulations (POST) validates create payload', async () => { await request(app.getHttpServer()).post('/api/v1/simulations').send({}).expect(400); }); ``` - [ ] **Step 2: Run the e2e suite to verify the business skeleton routes are missing** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: FAIL with 404 for `/api/v1/customers`, `/api/v1/templates`, or `/api/v1/simulations` - [ ] **Step 3: Add a reusable not-ready exception and the customers module** ```ts // backend/src/common/errors/not-ready.exception.ts import { NotImplementedException } from '@nestjs/common'; export class NotReadyException extends NotImplementedException { constructor(message: string) { super(message); } } ``` ```ts // backend/src/modules/customers/customers.service.ts import { Injectable } from '@nestjs/common'; import { NotReadyException } from '../../common/errors/not-ready.exception'; import { CustomerUpsertDto } from './dto/customer-upsert.dto'; import { ListCustomersQueryDto } from './dto/list-customers-query.dto'; @Injectable() export class CustomersService { async list(_query: ListCustomersQueryDto): Promise<{ items: never[]; total: number }> { throw new NotReadyException('Customers list is not implemented yet'); } async getById(_id: string): Promise { throw new NotReadyException('Customer detail is not implemented yet'); } async create(_body: CustomerUpsertDto): Promise { throw new NotReadyException('Customer creation is not implemented yet'); } async update(_id: string, _body: CustomerUpsertDto): Promise { throw new NotReadyException('Customer update is not implemented yet'); } } ``` ```ts // backend/src/modules/customers/customers.controller.ts import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { CustomerUpsertDto } from './dto/customer-upsert.dto'; import { CustomersService } from './customers.service'; import { ListCustomersQueryDto } from './dto/list-customers-query.dto'; @Controller('customers') export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Get() list(@Query() query: ListCustomersQueryDto) { return this.customersService.list(query); } @Get(':id') getById(@Param('id') id: string) { return this.customersService.getById(id); } @Post() create(@Body() body: CustomerUpsertDto) { return this.customersService.create(body); } @Patch(':id') update(@Param('id') id: string, @Body() body: CustomerUpsertDto) { return this.customersService.update(id, body); } } ``` ```ts // backend/src/modules/customers/customers.module.ts import { Module } from '@nestjs/common'; import { CustomersController } from './customers.controller'; import { CustomersService } from './customers.service'; @Module({ controllers: [CustomersController], providers: [CustomersService], }) export class CustomersModule {} ``` - [ ] **Step 4: Add templates and simulations modules with DTO validation and explicit 501 responses** ```ts // backend/src/modules/templates/templates.service.ts import { Injectable } from '@nestjs/common'; import { NotReadyException } from '../../common/errors/not-ready.exception'; import { ListTemplatesQueryDto } from './dto/list-templates-query.dto'; @Injectable() export class TemplatesService { async list(_query: ListTemplatesQueryDto): Promise<{ items: never[]; total: number }> { throw new NotReadyException('Templates list is not implemented yet'); } } ``` ```ts // backend/src/modules/templates/templates.controller.ts import { Controller, Get, Query } from '@nestjs/common'; import { ListTemplatesQueryDto } from './dto/list-templates-query.dto'; import { TemplatesService } from './templates.service'; @Controller('templates') export class TemplatesController { constructor(private readonly templatesService: TemplatesService) {} @Get() list(@Query() query: ListTemplatesQueryDto) { return this.templatesService.list(query); } } ``` ```ts // backend/src/modules/templates/templates.module.ts import { Module } from '@nestjs/common'; import { TemplatesController } from './templates.controller'; import { TemplatesService } from './templates.service'; @Module({ controllers: [TemplatesController], providers: [TemplatesService], }) export class TemplatesModule {} ``` ```ts // backend/src/modules/simulations/dto/create-simulation.dto.ts import { ArrayMinSize, IsArray, IsIn, IsOptional, IsString } from 'class-validator'; export class CreateSimulationDto { @IsIn(['selected', 'guest']) customerMode!: 'selected' | 'guest'; @IsOptional() @IsString() customerId?: string; @IsString() sourcePhoto!: string; @IsIn(['hairstyle', 'aftercare']) simulationType!: 'hairstyle' | 'aftercare'; @IsOptional() @IsArray() @ArrayMinSize(1) templateIds?: string[]; @IsOptional() @IsArray() @ArrayMinSize(1) aftercareTypes?: string[]; } ``` ```ts // backend/src/modules/simulations/simulations.service.ts import { Injectable } from '@nestjs/common'; import { NotReadyException } from '../../common/errors/not-ready.exception'; import { CreateSimulationDto } from './dto/create-simulation.dto'; @Injectable() export class SimulationsService { async create(_input: CreateSimulationDto): Promise { throw new NotReadyException('Simulation creation is not implemented yet'); } } ``` ```ts // backend/src/modules/simulations/simulations.controller.ts import { Body, Controller, Post } from '@nestjs/common'; import { CreateSimulationDto } from './dto/create-simulation.dto'; import { SimulationsService } from './simulations.service'; @Controller('simulations') export class SimulationsController { constructor(private readonly simulationsService: SimulationsService) {} @Post() create(@Body() body: CreateSimulationDto) { return this.simulationsService.create(body); } } ``` ```ts // backend/src/modules/simulations/simulations.module.ts import { Module } from '@nestjs/common'; import { SimulationsController } from './simulations.controller'; import { SimulationsService } from './simulations.service'; @Module({ controllers: [SimulationsController], providers: [SimulationsService], }) export class SimulationsModule {} ``` - [ ] **Step 5: Register all business skeleton modules and rerun the e2e suite** ```ts // backend/src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import appConfig from './config/app.config'; import { AuthModule } from './modules/auth/auth.module'; import { CustomersModule } from './modules/customers/customers.module'; import { HealthModule } from './modules/health/health.module'; import { SimulationsModule } from './modules/simulations/simulations.module'; import { TemplatesModule } from './modules/templates/templates.module'; import { PrismaModule } from './prisma/prisma.module'; import { RedisModule } from './redis/redis.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true, load: [appConfig] }), PrismaModule, RedisModule, HealthModule, AuthModule, CustomersModule, TemplatesModule, SimulationsModule, ], }) export class AppModule {} ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: - health and auth tests still pass - customers/templates return 501 envelopes with clear messages - invalid simulation payload returns 400 envelope - [ ] **Step 6: Commit the domain skeleton task** ```bash git add backend/src/common/errors/not-ready.exception.ts backend/src/modules/customers backend/src/modules/templates backend/src/modules/simulations backend/src/app.module.ts backend/test/app.e2e-spec.ts git commit -m "feat: add zero-mock business module skeletons" ``` ### Task 6: Finish Docs, Validation Commands, And Local Developer Experience **Files:** - Create: `backend/README.md` - Modify: `backend/package.json` - Modify: `backend/test/app.e2e-spec.ts` - Modify: `frontend/README.md` **Interfaces:** - Consumes: - runtime commands from Tasks 1-5 - endpoint availability from health/auth/business skeleton modules - Produces: - developer setup doc in `backend/README.md` - corrected cross-project setup notes in `frontend/README.md` - [ ] **Step 1: Add a final smoke test for the Swagger endpoint** ```ts it('/api/docs (GET)', async () => { await request(app.getHttpServer()).get('/api/docs').expect(200); }); ``` - [ ] **Step 2: Run tests to verify Swagger coverage fails before the README/doc pass is complete** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run test:e2e -- --runInBand` Expected: FAIL if Swagger setup path or static assets are not reachable in test context - [ ] **Step 3: Write backend setup documentation and correct frontend backend assumptions** ````md # Aitori Backend ## Requirements - Node.js 20.x - MySQL 8.x - Redis 7.x ## Setup ```bash npm install cp .env.example .env npm run prisma:generate npm run start:dev ``` ## Endpoints - Health: `http://localhost:3000/api/v1/health` - Swagger: `http://localhost:3000/api/docs` ```` ````md ## 后端准备 当前后端已经初始化为 `NestJS + Prisma + MySQL + Redis` 标准业务骨架。 ```bash cd backend npm install cp .env.example .env npm run prisma:generate npm run start:dev ``` ```` - [ ] **Step 4: Run the full verification suite** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run lint && npm run build && npm run test:e2e -- --runInBand && npm run prisma:validate` Expected: - lint passes with exit code `0` - build passes with exit code `0` - e2e passes with exit code `0` - Prisma schema validates successfully - [ ] **Step 5: Perform a frontend-facing smoke check** Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm run dev -- --host 127.0.0.1 --port 5173` Expected: - frontend starts successfully - with backend running, `/api/v1/health` is reachable through the configured API base - login page submission hits the backend login endpoint and receives a controlled 501 response instead of mock data - [ ] **Step 6: Commit the docs and verification task** ```bash git add backend/README.md backend/package.json backend/test/app.e2e-spec.ts frontend/README.md git commit -m "docs: add backend setup and verification guidance" ``` ## Self-Review - Spec coverage: Task 1 covers NestJS project initialization. Task 2 covers `/api/v1`, unified responses, validation, CORS, Swagger, and health. Task 3 covers Prisma/MySQL, Redis, `.env.example`, and health service dependencies. Task 4 covers auth DTOs, login contract shape, and explicit failure semantics. Task 5 covers customers, templates, simulations, dashboard-adjacent skeleton routes, and zero-mock behavior. Task 6 covers backend README, frontend README correction, Swagger verification, and final validation commands. - Placeholder scan: No `TODO`, `TBD`, or deferred “implement later” instructions remain in execution steps. Every task has exact files, commands, and code snippets. - Type consistency: `ApiEnvelope` remains the shared response type. `HealthService.getStatus()` grows from basic status to service-aware status in Task 3. Auth, customers, templates, and simulations service method names are referenced consistently between task interfaces and code snippets.