2026-06-28-backend-standard-business-skeleton.md 43 KB

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<void> in backend/src/main.ts

  • [ ] Step 1: Scaffold the NestJS project files

    {
    "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

    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

    // 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();
    }
    }
    
    // backend/src/app.service.ts
    import { Injectable } from '@nestjs/common';
    
    @Injectable()
    export class AppService {
    getRoot(): string {
    return 'Aitori backend bootstrap ready';
    }
    }
    
    // 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 {}
    
    // backend/src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap(): Promise<void> {
    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

    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<void> from backend/src/main.ts
  • Produces:

    • ApiEnvelope<T> type in backend/src/common/response/api-envelope.ts
    • ApiResponseInterceptor<T> 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

    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

    // 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',
    });
    
    // 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'),
    });
    
    // backend/src/common/response/api-envelope.ts
    export type ApiEnvelope<T> = {
    ok: boolean;
    data: T | null;
    error: { code: string; message: string } | null;
    };
    
    // 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<T> implements NestInterceptor<T, ApiEnvelope<T>> {
    intercept(_context: ExecutionContext, next: CallHandler): Observable<ApiEnvelope<T>> {
    return next.handle().pipe(
      map((data) => ({
        ok: true,
        data,
        error: null,
      })),
    );
    }
    }
    
    // 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<Response>();
    
    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',
      },
    });
    }
    }
    
    // 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(),
    };
    }
    }
    
    // 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();
    }
    }
    
    // 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<void> {
    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

    // 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 {}
    
    // 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

    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<void> and enableShutdownHooks(app: INestApplication): Promise<void>
    • 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

    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

    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
    
    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")
    }
    
    // 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<void> {
    await this.$connect();
    }
    
    async enableShutdownHooks(app: INestApplication): Promise<void> {
    this.$on('beforeExit', async () => {
      await app.close();
    });
    }
    }
    
    // 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

    // 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 },
    };
    }
    }
    
    // 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

    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<T> response wrapping
    • global ValidationPipe
  • Produces:

    • POST /api/v1/auth/stylist/login
    • POST /api/v1/auth/logout
    • AuthService.login(input: StylistLoginDto): Promise<LoginResultDto>
    • AuthService.logout(): Promise<{ success: true }>
  • [ ] Step 1: Add the failing auth e2e tests

    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

    // 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;
    }
    
    // backend/src/modules/auth/dto/login-result.dto.ts
    export class LoginResultDto {
    token!: string;
    tokenName!: string;
    stylistId!: number;
    name!: string;
    email!: string;
    }
    
    // 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<LoginResultDto> {
    throw new NotImplementedException('Stylist login is not implemented yet');
    }
    
    async logout(): Promise<{ success: true }> {
    return { success: true };
    }
    }
    
    // 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();
    }
    }
    
    // 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<string, string | undefined> }>();
    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

    // 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 {}
    
    // 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

    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<T>
    • 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<never>
    • explicit NotReadyException for unfinished business routes
  • [ ] Step 1: Add failing e2e tests for the skeleton business routes

    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

    // backend/src/common/errors/not-ready.exception.ts
    import { NotImplementedException } from '@nestjs/common';
    
    export class NotReadyException extends NotImplementedException {
    constructor(message: string) {
    super(message);
    }
    }
    
    // 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<never> {
    throw new NotReadyException('Customer detail is not implemented yet');
    }
    
    async create(_body: CustomerUpsertDto): Promise<never> {
    throw new NotReadyException('Customer creation is not implemented yet');
    }
    
    async update(_id: string, _body: CustomerUpsertDto): Promise<never> {
    throw new NotReadyException('Customer update is not implemented yet');
    }
    }
    
    // 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);
    }
    }
    
    // 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

    // 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');
    }
    }
    
    // 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);
    }
    }
    
    // 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 {}
    
    // 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[];
    }
    
    // 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<never> {
    throw new NotReadyException('Simulation creation is not implemented yet');
    }
    }
    
    // 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);
    }
    }
    
    // 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

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

    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

    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

    # 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`
    
    ## 后端准备
    
    当前后端已经初始化为 `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

    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<T> 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.