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
/api/v1.{ ok, data, error }.Authorization.PORT, APP_BASE_URL, DATABASE_URL, REDIS_URL, JWT_SECRET, JWT_EXPIRES_IN, CORS_ORIGIN, and NODE_ENV.hairstylist_user, customer_treatment_record, ai_hair_simulation, ai_hair_simulation_task, hair_template, and hair_template_make_task.Files:
backend/package.jsonbackend/tsconfig.jsonbackend/tsconfig.build.jsonbackend/nest-cli.jsonbackend/.gitignorebackend/.prettierrcbackend/eslint.config.mjsbackend/src/main.tsbackend/src/app.module.tsbackend/src/app.controller.tsbackend/src/app.service.tsbackend/test/jest-e2e.jsonbackend/test/app.e2e-spec.tsInterfaces:
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.tswebpack 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"
Files:
backend/src/main.tsbackend/src/app.module.tsbackend/src/config/app.config.tsbackend/src/config/env.schema.tsbackend/src/common/response/api-envelope.tsbackend/src/common/interceptors/api-response.interceptor.tsbackend/src/common/filters/http-exception.filter.tsbackend/src/modules/health/health.module.tsbackend/src/modules/health/health.controller.tsbackend/src/modules/health/health.service.tsbackend/test/app.e2e-spec.tsInterfaces:
bootstrap(): Promise<void> from backend/src/main.tsProduces:
ApiEnvelope<T> type in backend/src/common/response/api-envelope.tsApiResponseInterceptor<T> in backend/src/common/interceptors/api-response.interceptor.tsHttpExceptionFilter in backend/src/common/filters/http-exception.filter.tsHealthService.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.tsGET http://localhost:3000/api/v1/health returns wrapped health dataSwagger 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"
Files:
backend/package.jsonbackend/.env.examplebackend/prisma/schema.prismabackend/src/prisma/prisma.module.tsbackend/src/prisma/prisma.service.tsbackend/src/redis/redis.module.tsbackend/src/redis/redis.service.tsbackend/src/app.module.tsbackend/src/modules/health/health.service.tsbackend/test/app.e2e-spec.tsInterfaces:
HealthService.getStatus()AppModuleProduces:
PrismaService with onModuleInit(): Promise<void> and enableShutdownHooks(app: INestApplication): Promise<void>RedisService.ping(): Promise<'PONG' | 'UNAVAILABLE'>{ 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:
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"
Files:
backend/package.jsonbackend/src/modules/auth/auth.module.tsbackend/src/modules/auth/auth.controller.tsbackend/src/modules/auth/auth.service.tsbackend/src/modules/auth/dto/stylist-login.dto.tsbackend/src/modules/auth/dto/login-result.dto.tsbackend/src/modules/auth/guards/jwt-auth.guard.tsbackend/src/app.module.tsbackend/test/app.e2e-spec.tsInterfaces:
ApiEnvelope<T> response wrappingValidationPipeProduces:
POST /api/v1/auth/stylist/loginPOST /api/v1/auth/logoutAuthService.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:
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"
Files:
backend/src/common/errors/not-ready.exception.tsbackend/src/modules/customers/customers.module.tsbackend/src/modules/customers/customers.controller.tsbackend/src/modules/customers/customers.service.tsbackend/src/modules/customers/dto/list-customers-query.dto.tsbackend/src/modules/customers/dto/customer-upsert.dto.tsbackend/src/modules/templates/templates.module.tsbackend/src/modules/templates/templates.controller.tsbackend/src/modules/templates/templates.service.tsbackend/src/modules/templates/dto/list-templates-query.dto.tsbackend/src/modules/templates/dto/create-template.dto.tsbackend/src/modules/simulations/simulations.module.tsbackend/src/modules/simulations/simulations.controller.tsbackend/src/modules/simulations/simulations.service.tsbackend/src/modules/simulations/dto/create-simulation.dto.tsbackend/src/app.module.tsbackend/test/app.e2e-spec.tsInterfaces:
ApiEnvelope<T>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>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:
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"
Files:
backend/README.mdbackend/package.jsonbackend/test/app.e2e-spec.tsfrontend/README.mdInterfaces:
Produces:
backend/README.mdfrontend/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:
000Prisma 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:
/api/v1/health is reachable through the configured API baselogin 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"
/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.TODO, TBD, or deferred “implement later” instructions remain in execution steps. Every task has exact files, commands, and code snippets.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.