# AI Hair Simulation 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:** Build a real AI hair simulation flow that lets stylists optionally bind a customer, upload an origin image, choose stylist/system templates from `hair_template`, create `ai_hair_simulation` + `ai_hair_simulation_task` records, and view per-template task progress in a new detail page. **Architecture:** The backend owns task orchestration: it validates customer/template access, calls the external AI service, persists one simulation row plus multiple task rows, and refreshes `PROCESSING` tasks when the detail endpoint is polled. The frontend only talks to backend simulation endpoints, rewrites the create/detail pages around the new DTOs, and keeps `SimulationEditPage` / `AftercareResultPage` as undeveloped shells. **Tech Stack:** NestJS 11, Prisma + MySQL, React 19, React Router 7, Vitest, Jest, GCS upload API, external AI HTTP API ## Global Constraints - Implement only the AI hair simulation main flow; do not implement real `aftercare`. - Keep `SimulationEditPage` and `AftercareResultPage`, but mark both as undeveloped and remove old business logic. - The uploaded origin image must use upload type `ai-user-origin-image`. - Template data must come from `hair_template`; AI `POST /execute` must use `hair_template.template_url`. - Template list queries must only return rows where `is_deleted = 0`, `status = 'PUBLIC'`, and `template_url` is non-empty. - Control-panel entry defaults to “no customer”; customer-detail entry preselects that customer but still allows switching or clearing. - The task detail route must be `/:locale/simulation/result/:simulationId`. - The task detail page must show child tasks individually, including processing / success / failure and result images. - The frontend must poll backend detail only; the backend decides when to refresh `PROCESSING` tasks from the AI service. - Treat `ai_hair_simulation_task.template_id` as a string in DTOs and frontend contracts, converting to `BigInt` only inside backend data access. - Preserve existing customer detail UI skeleton and only turn its simulation button into a real link. - If any SQL migration is created while implementing this plan, every field must keep complete column comments. --- ## File Structure ### Backend - Modify: `backend/prisma/schema.prisma` - Align Prisma models with the latest `hair_template` design (`templateUrl`, `isSystemTemplate`, enum types). - Modify: `backend/src/config/env.schema.ts` - Add AI base URL and refresh/timing environment validation. - Modify: `backend/src/config/app.config.ts` - Expose typed `aiSimulation` config object. - Modify: `backend/src/config/app.config.spec.ts` - Cover the new config mapping. - Create: `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts` - Query DTO for template source/category/search/pagination. - Create: `backend/src/modules/simulations/dto/simulation-template-option.dto.ts` - Template list response DTO shape. - Create: `backend/src/modules/simulations/dto/simulation-detail.dto.ts` - Detail DTO with customer summary, counts, overall status, child tasks. - Create: `backend/src/modules/simulations/ai-simulation.client.ts` - External AI HTTP client (`execute`, `getResult`). - Modify: `backend/src/modules/simulations/dto/create-simulation.dto.ts` - Replace old `customerMode/simulationType` contract with the new request shape. - Modify: `backend/src/modules/simulations/simulations.controller.ts` - Add `GET /simulations/template-options` and `GET /simulations/:id`. - Modify: `backend/src/modules/simulations/simulations.service.ts` - Implement template listing, create flow, detail aggregation, refresh throttling. - Modify: `backend/src/modules/simulations/simulations.module.ts` - Register AI client and any needed providers/imports. - Create: `backend/src/modules/simulations/simulations.service.spec.ts` - Unit tests for template filters, create flow, detail refresh, overall status. - Create: `backend/test/simulations.e2e-spec.ts` - Auth + create/detail E2E coverage. ### Frontend - Modify: `frontend/src/config/routes.ts` - Add `simulationResultDetail` route with `:simulationId`. - Modify: `frontend/src/App.tsx` - Point simulation result route to the new path parameter. - Modify: `frontend/src/services/api/contracts.ts` - Add `SimulationTemplateOption`, `AiHairSimulationDetail`, `AiHairSimulationTaskDetail`. - Modify: `frontend/src/services/api/simulations.ts` - Remove obsolete helpers and expose only template list / create / detail. - Modify: `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx` - Rewrite as the create page for optional customer + upload + template source tabs. - Modify: `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx` - Rewrite as the task detail page with polling. - Modify: `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx` - Replace old adjustment flow with undeveloped state. - Modify: `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx` - Replace old aftercare flow with undeveloped state. - Modify: `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx` - Turn the simulation CTA into a live navigation link. - Modify: `frontend/src/i18n/locales/zh/translation.json` - Add/replace copy for the new create/detail pages and undeveloped shells. - Modify: `frontend/src/i18n/locales/ja/translation.json` - Mirror the new copy in Japanese. - Modify: `frontend/public/css/aitori.css` - Add styles for template tabs, task cards, counters, and undeveloped states if existing utility classes are insufficient. - Create: `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx` - Create-page tests for customer preselect/clear, upload type, submit navigation. - Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx` - Detail-page tests for polling and task rendering. - Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx` - Undeveloped shell coverage for edit/aftercare pages. - Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` - Assert the simulation CTA navigates with `customerId`. - Modify: `frontend/src/__tests__/App.test.tsx` - Update route expectations for `/:locale/simulation/result/:simulationId`. ## Task 1: Create Branch And Align Backend Config Surface **Files:** - Modify: `backend/prisma/schema.prisma` - Modify: `backend/src/config/env.schema.ts` - Modify: `backend/src/config/app.config.ts` - Modify: `backend/src/config/app.config.spec.ts` **Interfaces:** - Consumes: Existing `ConfigModule.forRoot()` behavior and current Prisma models. - Produces: - `config.aiSimulation.baseUrl: string` - `config.aiSimulation.refreshIntervalMs: number` - `config.aiSimulation.httpTimeoutMs: number` - Prisma `HairTemplate.templateUrl: string` - Prisma `HairTemplate.isSystemTemplate: 'YES' | 'NO'` - [ ] **Step 1: Create the feature branch** Run: ```bash git switch -c feature/ai-hair-simulation ``` Expected: terminal prints `Switched to a new branch 'feature/ai-hair-simulation'` - [ ] **Step 2: Write the failing backend config test** Add to `backend/src/config/app.config.spec.ts`: ```ts it('maps ai simulation config from env', () => { process.env.AI_SIMULATION_BASE_URL = 'http://localhost:8084'; process.env.AI_SIMULATION_REFRESH_INTERVAL_MS = '10000'; process.env.AI_SIMULATION_HTTP_TIMEOUT_MS = '15000'; const config = appConfig(); expect(config.aiSimulation).toEqual({ baseUrl: 'http://localhost:8084', refreshIntervalMs: 10000, httpTimeoutMs: 15000, }); }); ``` - [ ] **Step 3: Run the config spec to verify it fails** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/config/app.config.spec.ts --runInBand ``` Expected: FAIL because `aiSimulation` is missing from the config object - [ ] **Step 4: Implement config + schema alignment** Update `backend/src/config/env.schema.ts`: ```ts AI_SIMULATION_BASE_URL: z.string().url().default('http://localhost:8084'), AI_SIMULATION_REFRESH_INTERVAL_MS: z.coerce.number().int().positive().default(10000), AI_SIMULATION_HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(15000), ``` Update `backend/src/config/app.config.ts`: ```ts aiSimulation: { baseUrl: process.env.AI_SIMULATION_BASE_URL ?? 'http://localhost:8084', refreshIntervalMs: Number(process.env.AI_SIMULATION_REFRESH_INTERVAL_MS ?? 10000), httpTimeoutMs: Number(process.env.AI_SIMULATION_HTTP_TIMEOUT_MS ?? 15000), }, ``` Update `backend/prisma/schema.prisma` in `HairTemplate`: ```prisma enum HairTemplateStatus { DRAFT PUBLIC } enum HairTemplateCategory { LONG MEDIUM SHORT DYE } enum HairTemplateSystemFlag { YES NO } model HairTemplate { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt templateName String @map("template_name") @db.VarChar(100) coverUrl String @default("") @map("cover_url") @db.VarChar(500) status HairTemplateStatus category HairTemplateCategory tags String @default("") @db.VarChar(500) description String? @db.Text modelUrl String @default("") @map("model_url") @db.VarChar(500) clothesUrl String @default("") @map("clothes_url") @db.VarChar(500) backgroundUrl String @default("") @map("background_url") @db.VarChar(500) refHairUrl String @default("") @map("ref_hair_url") @db.VarChar(500) hairstylistId BigInt? @map("hairstylist_id") @db.UnsignedBigInt templateUrl String @default("") @map("template_url") @db.VarChar(500) isSystemTemplate HairTemplateSystemFlag @default(NO) @map("is_system_template") createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@index([category, isDeleted], map: "idx_category") @@index([hairstylistId, isDeleted], map: "idx_hairstylist_id") @@index([status, isDeleted], map: "idx_status") @@map("hair_template") } ``` - [ ] **Step 5: Run config and Prisma validation** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/config/app.config.spec.ts --runInBand npm run prisma:validate ``` Expected: ```text PASS src/config/app.config.spec.ts The schema at prisma/schema.prisma is valid ``` - [ ] **Step 6: Commit** ```bash git add backend/prisma/schema.prisma backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts git commit -m "chore: align ai simulation config and prisma schema" ``` ## Task 2: Build Backend Template Options Endpoint **Files:** - Create: `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts` - Create: `backend/src/modules/simulations/dto/simulation-template-option.dto.ts` - Modify: `backend/src/modules/simulations/simulations.controller.ts` - Modify: `backend/src/modules/simulations/simulations.service.ts` - Create: `backend/src/modules/simulations/simulations.service.spec.ts` **Interfaces:** - Consumes: - `HairTemplate.status` - `HairTemplate.isSystemTemplate` - logged-in stylist id from existing auth context service/controller pattern - Produces: - `GET /api/v1/simulations/template-options` - `listTemplateOptions(stylistId: bigint, query: ListSimulationTemplateOptionsQueryDto): Promise<{ items: SimulationTemplateOptionDto[]; total: number; page: number; pageSize: number }>` - [ ] **Step 1: Write the failing unit tests for source filtering** Create `backend/src/modules/simulations/simulations.service.spec.ts` with: ```ts it('returns only public stylist templates for the current stylist', async () => { prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob', coverUrl: 'https://cdn.example.com/bob.png', category: 'SHORT', tags: 'airy,bob', description: 'short bob', templateUrl: 'https://cdn.example.com/bob.zip', isSystemTemplate: 'NO', }, ] as any); prisma.hairTemplate.count.mockResolvedValue(1); const result = await service.listTemplateOptions(BigInt(7), { source: 'STYLIST', page: 1, pageSize: 12, }); expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isDeleted: 0, status: 'PUBLIC', isSystemTemplate: 'NO', hairstylistId: BigInt(7), }), }), ); expect(result.items[0]).toMatchObject({ id: '11', templateName: 'Layer Bob', tags: ['airy', 'bob'], isSystemTemplate: false, }); }); it('returns only public system templates for the system tab', async () => { prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(21), templateName: 'Classic Medium', coverUrl: '', category: 'MEDIUM', tags: '', description: null, templateUrl: 'https://cdn.example.com/medium.zip', isSystemTemplate: 'YES', }, ] as any); prisma.hairTemplate.count.mockResolvedValue(1); const result = await service.listTemplateOptions(BigInt(7), { source: 'SYSTEM', page: 1, pageSize: 12, }); expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isDeleted: 0, status: 'PUBLIC', isSystemTemplate: 'YES', }), }), ); expect(result.items[0].id).toBe('21'); }); ``` - [ ] **Step 2: Run the service spec to verify it fails** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand ``` Expected: FAIL because `listTemplateOptions()` and the DTOs do not exist - [ ] **Step 3: Implement the query DTOs and endpoint** Create `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts`: ```ts import { Transform } from 'class-transformer'; import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; export class ListSimulationTemplateOptionsQueryDto { @IsEnum(['STYLIST', 'SYSTEM']) source!: 'STYLIST' | 'SYSTEM'; @IsOptional() @IsEnum(['LONG', 'MEDIUM', 'SHORT', 'DYE']) category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; @IsOptional() @IsString() q?: string; @Transform(({ value }) => Number(value ?? 1)) @IsInt() @Min(1) page = 1; @Transform(({ value }) => Number(value ?? 12)) @IsInt() @Min(1) pageSize = 12; } ``` Create `backend/src/modules/simulations/dto/simulation-template-option.dto.ts`: ```ts export type SimulationTemplateOptionDto = { id: string; templateName: string; coverUrl: string; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; tags: string[]; description: string | null; templateUrl: string; isSystemTemplate: boolean; }; ``` Add to `backend/src/modules/simulations/simulations.controller.ts`: ```ts @Get('template-options') listTemplateOptions( @CurrentUser() user: AuthUser, @Query() query: ListSimulationTemplateOptionsQueryDto, ) { return this.simulationsService.listTemplateOptions(BigInt(user.userId), query); } ``` Add to `backend/src/modules/simulations/simulations.service.ts`: ```ts async listTemplateOptions( hairstylistId: bigint, query: ListSimulationTemplateOptionsQueryDto, ) { const where = { isDeleted: 0, status: 'PUBLIC' as const, templateUrl: { not: '' }, ...(query.category ? { category: query.category } : {}), ...(query.q ? { templateName: { contains: query.q } } : {}), ...(query.source === 'STYLIST' ? { isSystemTemplate: 'NO' as const, hairstylistId } : { isSystemTemplate: 'YES' as const }), }; const [items, total] = await Promise.all([ this.prisma.hairTemplate.findMany({ where, orderBy: { id: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize, }), this.prisma.hairTemplate.count({ where }), ]); return { items: items.map((item) => ({ id: item.id.toString(), templateName: item.templateName, coverUrl: item.coverUrl, category: item.category, tags: item.tags ? item.tags.split(',').map((tag) => tag.trim()).filter(Boolean) : [], description: item.description, templateUrl: item.templateUrl, isSystemTemplate: item.isSystemTemplate === 'YES', })), total, page: query.page, pageSize: query.pageSize, }; } ``` - [ ] **Step 4: Run backend unit tests** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand ``` Expected: ```text PASS src/modules/simulations/simulations.service.spec.ts ``` - [ ] **Step 5: Commit** ```bash git add backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts backend/src/modules/simulations/dto/simulation-template-option.dto.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts git commit -m "feat: add simulation template options endpoint" ``` ## Task 3: Implement Backend Simulation Creation Flow **Files:** - Create: `backend/src/modules/simulations/ai-simulation.client.ts` - Modify: `backend/src/modules/simulations/dto/create-simulation.dto.ts` - Modify: `backend/src/modules/simulations/simulations.module.ts` - Modify: `backend/src/modules/simulations/simulations.controller.ts` - Modify: `backend/src/modules/simulations/simulations.service.ts` - Modify: `backend/src/modules/simulations/simulations.service.spec.ts` - Create: `backend/test/simulations.e2e-spec.ts` **Interfaces:** - Consumes: - `listTemplateOptions` task output - `config.aiSimulation` - existing authenticated user in controller - Produces: - `AiSimulationClient.execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }>` - `createSimulation(stylistId: bigint, dto: CreateSimulationDto): Promise<{ simulationId: string }>` - `POST /api/v1/simulations` - [ ] **Step 1: Write failing tests for create validation and persistence** Extend `backend/src/modules/simulations/simulations.service.spec.ts` with: ```ts it('creates one simulation and one task row per selected template', async () => { prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) } as any); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateUrl: 'https://cdn.example.com/11.zip', isSystemTemplate: 'NO', hairstylistId: BigInt(7), status: 'PUBLIC', isDeleted: 0, }, { id: BigInt(12), templateUrl: 'https://cdn.example.com/12.zip', isSystemTemplate: 'YES', hairstylistId: null, status: 'PUBLIC', isDeleted: 0, }, ] as any); aiClient.execute .mockResolvedValueOnce({ taskId: 'task-11' }) .mockResolvedValueOnce({ taskId: 'task-12' }); prisma.$transaction.mockImplementation(async (callback: any) => callback({ aiHairSimulation: { create: jest.fn().mockResolvedValue({ id: BigInt(91) }), }, aiHairSimulationTask: { createMany: jest.fn().mockResolvedValue({ count: 2 }), }, }), ); const result = await service.createSimulation(BigInt(7), { customerId: '5', originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11', '12'], simulateRemark: 'soft volume', }); expect(aiClient.execute).toHaveBeenNthCalledWith( 1, 'https://cdn.example.com/11.zip', 'https://cdn.example.com/origin.jpg', ); expect(result).toEqual({ simulationId: '91' }); }); it('rejects customer ids outside the current stylist scope', async () => { prisma.customer.findFirst.mockResolvedValue(null); await expect( service.createSimulation(BigInt(7), { customerId: '999', originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11'], }), ).rejects.toThrow('顾客不存在'); }); ``` - [ ] **Step 2: Run the service test to verify it fails** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand ``` Expected: FAIL because the new create contract and AI client are not implemented - [ ] **Step 3: Implement the AI client, DTO, and create path** Rewrite `backend/src/modules/simulations/dto/create-simulation.dto.ts`: ```ts import { ArrayMinSize, IsArray, IsOptional, IsString, IsUrl } from 'class-validator'; export class CreateSimulationDto { @IsOptional() @IsString() customerId?: string; @IsUrl() originImage!: string; @IsArray() @ArrayMinSize(1) @IsString({ each: true }) templateIds!: string[]; @IsOptional() @IsString() simulateRemark?: string; } ``` Create `backend/src/modules/simulations/ai-simulation.client.ts`: ```ts import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @Injectable() export class AiSimulationClient { constructor(private readonly configService: ConfigService) {} async execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }> { const response = await fetch( `${this.configService.getOrThrow('aiSimulation.baseUrl')}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template: templateUrl, inputs: { images: [imageUrl], }, }), }, ); if (!response.ok) { throw new ServiceUnavailableException('AI 模拟服务暂时不可用'); } const data = (await response.json()) as { taskId?: string }; if (!data.taskId) { throw new ServiceUnavailableException('AI 模拟任务创建失败'); } return { taskId: data.taskId }; } } ``` Add to `backend/src/modules/simulations/simulations.service.ts`: ```ts async createSimulation(hairstylistId: bigint, dto: CreateSimulationDto) { if (dto.customerId) { const customer = await this.prisma.customer.findFirst({ where: { id: BigInt(dto.customerId), hairstylistId, isDeleted: 0, }, select: { id: true }, }); if (!customer) { throw new NotFoundException('顾客不存在'); } } const templateIds = dto.templateIds.map((value) => BigInt(value)); const templates = await this.prisma.hairTemplate.findMany({ where: { id: { in: templateIds }, isDeleted: 0, status: 'PUBLIC', templateUrl: { not: '' }, OR: [ { isSystemTemplate: 'YES' }, { isSystemTemplate: 'NO', hairstylistId }, ], }, }); if (templates.length !== dto.templateIds.length) { throw new BadRequestException('部分模板不可用'); } const taskSeeds = []; for (const template of templates) { const { taskId } = await this.aiSimulationClient.execute( template.templateUrl, dto.originImage, ); taskSeeds.push({ template, taskId, }); } return this.prisma.$transaction(async (tx) => { const simulation = await tx.aiHairSimulation.create({ data: { customerId: dto.customerId ? BigInt(dto.customerId) : null, hairstylistId, originImage: dto.originImage, simulateRemark: dto.simulateRemark ?? null, }, }); await tx.aiHairSimulationTask.createMany({ data: taskSeeds.map((seed) => ({ simulationId: simulation.id, templateId: seed.template.id.toString(), customerId: dto.customerId ? BigInt(dto.customerId) : null, taskId: seed.taskId, taskStatus: 'PROCESSING', resultImages: null, taskStartTime: new Date(), taskFinishTime: null, errorMsg: null, })), }); return { simulationId: simulation.id.toString() }; }); } ``` - [ ] **Step 4: Add a focused E2E test for the create endpoint** Create `backend/test/simulations.e2e-spec.ts`: ```ts it('/api/v1/simulations (POST) creates an anonymous simulation', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/simulations') .set('Authorization', `Bearer ${token}`) .send({ originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11'], simulateRemark: 'airy finish', }) .expect(201); expect(response.body.data).toEqual({ simulationId: expect.any(String), }); }); ``` - [ ] **Step 5: Run unit + e2e tests** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand ``` Expected: ```text PASS src/modules/simulations/simulations.service.spec.ts PASS test/simulations.e2e-spec.ts ``` - [ ] **Step 6: Commit** ```bash git add backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/dto/create-simulation.dto.ts backend/src/modules/simulations/simulations.module.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts git commit -m "feat: create ai hair simulations" ``` ## Task 4: Implement Backend Detail Endpoint And Refresh Throttling **Files:** - Create: `backend/src/modules/simulations/dto/simulation-detail.dto.ts` - Modify: `backend/src/modules/simulations/ai-simulation.client.ts` - Modify: `backend/src/modules/simulations/simulations.controller.ts` - Modify: `backend/src/modules/simulations/simulations.service.ts` - Modify: `backend/src/modules/simulations/simulations.service.spec.ts` - Modify: `backend/test/simulations.e2e-spec.ts` **Interfaces:** - Consumes: - `AiSimulationClient.execute()` - `config.aiSimulation.refreshIntervalMs` - Produces: - `AiSimulationClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>` - `getSimulationDetail(stylistId: bigint, simulationId: string): Promise` - `GET /api/v1/simulations/:simulationId` - [ ] **Step 1: Write failing tests for refresh and aggregate status** Extend `backend/src/modules/simulations/simulations.service.spec.ts` with: ```ts it('refreshes stale processing tasks and returns PARTIAL_SUCCESS when mixed', async () => { prisma.aiHairSimulation.findFirst.mockResolvedValue({ id: BigInt(91), customerId: BigInt(5), hairstylistId: BigInt(7), originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: 'airy', customer: { id: BigInt(5), name: 'Mika', avatar: '', phone: '090', expectContact: 'LINE', }, } as any); prisma.aiHairSimulationTask.findMany.mockResolvedValue([ { id: BigInt(1), templateId: '11', taskId: 'task-11', taskStatus: 'PROCESSING', resultImages: null, errorMsg: null, taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: null, updateTime: new Date('2026-06-28T09:59:00Z'), }, { id: BigInt(2), templateId: '12', taskId: 'task-12', taskStatus: 'FAILED', resultImages: null, errorMsg: 'template invalid', taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: new Date('2026-06-28T10:02:00Z'), updateTime: new Date('2026-06-28T10:02:00Z'), }, ] as any); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob', coverUrl: '', category: 'SHORT', isSystemTemplate: 'NO', }, { id: BigInt(12), templateName: 'Classic Medium', coverUrl: '', category: 'MEDIUM', isSystemTemplate: 'YES', }, ] as any); aiClient.getResult.mockResolvedValue({ status: 'successes', message: '', images: ['https://cdn.example.com/result.png'], }); const result = await service.getSimulationDetail(BigInt(7), '91'); expect(aiClient.getResult).toHaveBeenCalledWith('task-11'); expect(prisma.aiHairSimulationTask.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ taskStatus: 'SUCCESSES', resultImages: JSON.stringify(['https://cdn.example.com/result.png']), }), }), ); expect(result.overallStatus).toBe('PARTIAL_SUCCESS'); }); ``` - [ ] **Step 2: Run the backend service spec to verify it fails** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand ``` Expected: FAIL because `getSimulationDetail()` and `getResult()` do not exist - [ ] **Step 3: Implement detail DTO, AI result fetch, and refresh logic** Create `backend/src/modules/simulations/dto/simulation-detail.dto.ts`: ```ts export type AiHairSimulationTaskDetailDto = { id: string; templateId: string; templateName: string; templateCoverUrl: string; templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; templateSource: 'STYLIST' | 'SYSTEM'; taskId: string; taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED'; resultImages: string[]; errorMsg: string | null; taskStartTime: string; taskFinishTime: string | null; }; export type AiHairSimulationDetailDto = { id: string; customer: { id: string; name: string; avatar: string; phone: string; expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'; } | null; originImage: string; simulateRemark: string | null; totalTaskCount: number; processingTaskCount: number; successTaskCount: number; failedTaskCount: number; overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED'; tasks: AiHairSimulationTaskDetailDto[]; }; ``` Extend `backend/src/modules/simulations/ai-simulation.client.ts`: ```ts async getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[]; }> { const response = await fetch( `${this.configService.getOrThrow('aiSimulation.baseUrl')}/result?taskId=${encodeURIComponent(taskId)}`, ); if (!response.ok) { throw new ServiceUnavailableException('AI 模拟结果查询失败'); } const data = (await response.json()) as { status: 'processing' | 'successes' | 'failed'; message?: string; data?: { images?: string[] | null } | null; }; return { status: data.status, message: data.message ?? '', images: data.data?.images?.filter(Boolean) ?? [], }; } ``` Add to `backend/src/modules/simulations/simulations.service.ts`: ```ts private shouldRefreshTask(updateTime: Date): boolean { const refreshIntervalMs = this.configService.getOrThrow('aiSimulation.refreshIntervalMs'); return Date.now() - updateTime.getTime() >= refreshIntervalMs; } async getSimulationDetail(hairstylistId: bigint, simulationId: string) { const simulation = await this.prisma.aiHairSimulation.findFirst({ where: { id: BigInt(simulationId), hairstylistId, isDeleted: 0, }, include: { customer: { select: { id: true, name: true, avatar: true, phone: true, expectContact: true, }, }, }, }); if (!simulation) { throw new NotFoundException('模拟任务不存在'); } const tasks = await this.prisma.aiHairSimulationTask.findMany({ where: { simulationId: simulation.id, isDeleted: 0, }, orderBy: { id: 'asc' }, }); for (const task of tasks) { if (task.taskStatus !== 'PROCESSING') continue; if (!this.shouldRefreshTask(task.updateTime)) continue; try { const result = await this.aiSimulationClient.getResult(task.taskId); if (result.status === 'processing') { continue; } await this.prisma.aiHairSimulationTask.update({ where: { id: task.id }, data: result.status === 'successes' ? { taskStatus: 'SUCCESSES', resultImages: JSON.stringify(result.images), taskFinishTime: new Date(), errorMsg: null, } : { taskStatus: 'FAILED', errorMsg: result.message || 'AI 模拟失败', taskFinishTime: new Date(), }, }); } catch (error) { this.logger.warn(`Failed to refresh simulation task ${task.taskId}`); } } const freshTasks = await this.prisma.aiHairSimulationTask.findMany({ where: { simulationId: simulation.id, isDeleted: 0 }, orderBy: { id: 'asc' }, }); const templateIds = freshTasks.map((task) => BigInt(task.templateId)); const templates = await this.prisma.hairTemplate.findMany({ where: { id: { in: templateIds } }, }); const templateMap = new Map(templates.map((item) => [item.id.toString(), item])); const successTaskCount = freshTasks.filter((task) => task.taskStatus === 'SUCCESSES').length; const failedTaskCount = freshTasks.filter((task) => task.taskStatus === 'FAILED').length; const processingTaskCount = freshTasks.filter((task) => task.taskStatus === 'PROCESSING').length; return { id: simulation.id.toString(), customer: simulation.customer ? { id: simulation.customer.id.toString(), name: simulation.customer.name, avatar: simulation.customer.avatar, phone: simulation.customer.phone, expectContact: simulation.customer.expectContact, } : null, originImage: simulation.originImage, simulateRemark: simulation.simulateRemark, totalTaskCount: freshTasks.length, processingTaskCount, successTaskCount, failedTaskCount, overallStatus: processingTaskCount > 0 ? 'PROCESSING' : successTaskCount > 0 && failedTaskCount > 0 ? 'PARTIAL_SUCCESS' : successTaskCount === freshTasks.length ? 'SUCCESSES' : 'FAILED', tasks: freshTasks.map((task) => { const template = templateMap.get(task.templateId); return { id: task.id.toString(), templateId: task.templateId, templateName: template?.templateName ?? 'Unknown Template', templateCoverUrl: template?.coverUrl ?? '', templateCategory: (template?.category ?? 'LONG') as 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE', templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST', taskId: task.taskId, taskStatus: task.taskStatus, resultImages: task.resultImages ? JSON.parse(task.resultImages) : [], errorMsg: task.errorMsg, taskStartTime: task.taskStartTime.toISOString(), taskFinishTime: task.taskFinishTime?.toISOString() ?? null, }; }), }; } ``` - [ ] **Step 4: Add E2E coverage for authenticated detail** Extend `backend/test/simulations.e2e-spec.ts`: ```ts it('/api/v1/simulations/:id (GET) returns task detail for the owner', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/simulations/${simulationId}`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.data).toMatchObject({ id: simulationId, tasks: expect.any(Array), overallStatus: expect.any(String), }); }); ``` - [ ] **Step 5: Run unit + e2e tests** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand ``` Expected: ```text PASS src/modules/simulations/simulations.service.spec.ts PASS test/simulations.e2e-spec.ts ``` - [ ] **Step 6: Commit** ```bash git add backend/src/modules/simulations/dto/simulation-detail.dto.ts backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts git commit -m "feat: add simulation detail refresh flow" ``` ## Task 5: Replace Frontend Simulation API Contracts And Routes **Files:** - Modify: `frontend/src/config/routes.ts` - Modify: `frontend/src/App.tsx` - Modify: `frontend/src/services/api/contracts.ts` - Modify: `frontend/src/services/api/simulations.ts` - Modify: `frontend/src/__tests__/App.test.tsx` **Interfaces:** - Consumes: - backend `/simulations/template-options` - backend `/simulations` - backend `/simulations/:id` - Produces: - `routes.simulationResultDetail = '/:locale/simulation/result/:simulationId'` - `listSimulationTemplates(params)` - `createSimulation(input)` - `getSimulationById(simulationId)` - [ ] **Step 1: Write failing frontend API and route tests** Add to `frontend/src/__tests__/App.test.tsx`: ```tsx it('renders simulation result detail route with simulationId param', async () => { window.history.pushState({}, '', '/zh/simulation/result/91'); render(); expect(await screen.findByText(/Aitori Stylist/i)).toBeInTheDocument(); }); ``` Create or extend `frontend/src/services/api/__tests__/integration-adapters.test.ts` with: ```ts it('builds simulation detail requests with path params', async () => { vi.spyOn(http, 'get').mockResolvedValue({ id: '91' } as never); await getSimulationById('91'); expect(http.get).toHaveBeenCalledWith('/simulations/91'); }); ``` - [ ] **Step 2: Run the frontend tests to verify they fail** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts ``` Expected: FAIL because the detail route and API helper do not exist - [ ] **Step 3: Implement the new route and API surface** Update `frontend/src/config/routes.ts`: ```ts simulationResultDetail: '/:locale/simulation/result/:simulationId', ``` Update `frontend/src/App.tsx`: ```tsx } /> ``` Update `frontend/src/services/api/contracts.ts`: ```ts export type SimulationTemplateOption = { id: string; templateName: string; coverUrl: string; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; tags: string[]; description: string | null; templateUrl: string; isSystemTemplate: boolean; }; export type AiHairSimulationTaskDetail = { id: string; templateId: string; templateName: string; templateCoverUrl: string; templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; templateSource: 'STYLIST' | 'SYSTEM'; taskId: string; taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED'; resultImages: string[]; errorMsg: string | null; taskStartTime: string; taskFinishTime: string | null; }; export type AiHairSimulationDetail = { id: string; customer: { id: string; name: string; avatar: string; phone: string; expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'; } | null; originImage: string; simulateRemark: string | null; totalTaskCount: number; processingTaskCount: number; successTaskCount: number; failedTaskCount: number; overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED'; tasks: AiHairSimulationTaskDetail[]; }; ``` Rewrite `frontend/src/services/api/simulations.ts` to: ```ts import { http } from '../http/client'; import type { AiHairSimulationDetail, ListResponse, SimulationTemplateOption } from './contracts'; export async function listSimulationTemplates(params: { source: 'STYLIST' | 'SYSTEM'; category?: string; q?: string; page?: number; pageSize?: number; }): Promise & { page: number; pageSize: number }> { const query = new URLSearchParams(); query.set('source', params.source); if (params.category) query.set('category', params.category); if (params.q) query.set('q', params.q); if (params.page) query.set('page', String(params.page)); if (params.pageSize) query.set('pageSize', String(params.pageSize)); return http.get(`/simulations/template-options?${query.toString()}`); } export async function createSimulation(input: { customerId?: string; originImage: string; templateIds: string[]; simulateRemark?: string; }): Promise<{ simulationId: string }> { return http.post('/simulations', input); } export async function getSimulationById(simulationId: string): Promise { return http.get(`/simulations/${simulationId}`); } ``` Remove obsolete exports in that file: ```ts // delete: // getSimulationJob // getSimulationResults // waitForSimulationJob // saveSimulationResult // adjustSimulationResult // saveAftercareFollow ``` - [ ] **Step 4: Run the frontend tests** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts npm run typecheck ``` Expected: ```text PASS src/__tests__/App.test.tsx PASS src/services/api/__tests__/integration-adapters.test.ts ``` - [ ] **Step 5: Commit** ```bash git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api/contracts.ts frontend/src/services/api/simulations.ts frontend/src/__tests__/App.test.tsx frontend/src/services/api/__tests__/integration-adapters.test.ts git commit -m "refactor: replace simulation api contracts" ``` ## Task 6: Rewrite The Frontend Simulation Create Page **Files:** - Modify: `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - Modify: `frontend/public/css/aitori.css` - Create: `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx` **Interfaces:** - Consumes: - `listCustomers()` - `uploadMedia(file, 'ai-user-origin-image')` - `listSimulationTemplates()` - `createSimulation()` - Produces: - Create page with optional customer binding - Template source tabs (`STYLIST` / `SYSTEM`) - Navigation to `/simulation/result/:simulationId` - [ ] **Step 1: Write failing create-page tests** Create `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx`: ```tsx it('defaults to no customer when opened from dashboard', async () => { vi.mocked(listCustomers).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 10 }); vi.mocked(listSimulationTemplates).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 12, }); renderWithRouter('/zh/simulation'); expect(await screen.findByLabelText(/不绑定顾客/i)).toBeChecked(); }); it('preselects the customer from query string and uploads origin image with ai-user-origin-image', async () => { vi.mocked(listCustomers).mockResolvedValue({ items: [{ id: '5', name: 'Mika', phone: '090' }], total: 1, page: 1, pageSize: 10, } as never); vi.mocked(listSimulationTemplates).mockResolvedValue({ items: [{ id: '11', templateName: 'Layer Bob', coverUrl: '', category: 'SHORT', tags: [], description: null, templateUrl: 'x', isSystemTemplate: false }], total: 1, page: 1, pageSize: 12, }); vi.mocked(uploadMedia).mockResolvedValue({ url: 'https://cdn.example.com/origin.jpg' } as never); vi.mocked(createSimulation).mockResolvedValue({ simulationId: '91' }); renderWithRouter('/zh/simulation?customerId=5'); expect(await screen.findByDisplayValue('5')).toBeInTheDocument(); await userEvent.upload(screen.getByLabelText(/上传顾客照片/i), file); await userEvent.click(screen.getByRole('button', { name: /发起模拟/i })); expect(uploadMedia).toHaveBeenCalledWith(expect.any(File), 'ai-user-origin-image'); expect(createSimulation).toHaveBeenCalledWith( expect.objectContaining({ customerId: '5', originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11'], }), ); }); ``` - [ ] **Step 2: Run the create-page tests to verify they fail** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx ``` Expected: FAIL because the page still uses `selected/guest` and the old API surface - [ ] **Step 3: Rewrite the create page** Update `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx` around the form state: ```tsx const [bindCustomer, setBindCustomer] = useState(Boolean(searchParams.get('customerId'))); const [selectedCustomerId, setSelectedCustomerId] = useState(searchParams.get('customerId') ?? ''); const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST'); const [selectedTemplates, setSelectedTemplates] = useState([]); const [originImageUrl, setOriginImageUrl] = useState(''); const handleUpload = async (file: File) => { const uploaded = await uploadMedia(file, 'ai-user-origin-image'); setOriginImageUrl(uploaded.url); }; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!originImageUrl) { setError(copy.simulationCreatePage.errors.originRequired); return; } if (selectedTemplates.length === 0) { setError(copy.simulationCreatePage.errors.templateRequired); return; } const response = await createSimulation({ customerId: bindCustomer && selectedCustomerId ? selectedCustomerId : undefined, originImage: originImageUrl, templateIds: selectedTemplates, simulateRemark, }); navigate(path(`/simulation/result/${response.simulationId}`)); }; ``` Use source-tab fetching: ```tsx useEffect(() => { void listSimulationTemplates({ source: templateSource, page: templatePage, pageSize: 12, category: selectedCategory || undefined, q: keyword || undefined, }).then((result) => { setTemplates(result.items); setTemplateTotal(result.total); }); }, [templateSource, templatePage, selectedCategory, keyword]); ``` Required translation keys in `frontend/src/i18n/locales/zh/translation.json`: ```json "simulationCreatePage": { "title": "AI发型模拟", "bindCustomer": "绑定顾客", "noCustomer": "不绑定顾客", "myTemplates": "我的模板", "systemTemplates": "系统模板", "upload": "上传顾客照片", "submit": "发起模拟", "errors": { "originRequired": "请先上传顾客照片", "templateRequired": "请至少选择一个模板", "customerRequired": "请选择顾客" } } ``` Add the corresponding Japanese keys in `frontend/src/i18n/locales/ja/translation.json`. Add CSS in `frontend/public/css/aitori.css`: ```css .simulation-source-tabs { display: flex; gap: 12px; margin-bottom: 24px; } .simulation-source-tab.is-active { background: #232323; color: #fff; } .simulation-task-card { border: 1px solid #e5e5e5; border-radius: 12px; padding: 20px; } ``` - [ ] **Step 4: Run page tests and typecheck** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx npm run typecheck ``` Expected: ```text PASS src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx git commit -m "feat: rewrite ai simulation create page" ``` ## Task 7: Rewrite The Task Detail Page And Activate Customer Entry **Files:** - Modify: `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx` - Modify: `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx` - Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx` - Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - Modify: `frontend/public/css/aitori.css` **Interfaces:** - Consumes: - `getSimulationById(simulationId)` - `routes.simulationResultDetail` - Produces: - Polling result page bound to `simulationId` - Live CTA from customer detail into the create page - [ ] **Step 1: Write failing detail and CTA tests** Create `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx`: ```tsx it('polls while overallStatus is PROCESSING and renders child task cards', async () => { vi.useFakeTimers(); vi.mocked(getSimulationById) .mockResolvedValueOnce({ id: '91', customer: null, originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: 'airy', totalTaskCount: 1, processingTaskCount: 1, successTaskCount: 0, failedTaskCount: 0, overallStatus: 'PROCESSING', tasks: [ { id: '1', templateId: '11', templateName: 'Layer Bob', templateCoverUrl: '', templateCategory: 'SHORT', templateSource: 'STYLIST', taskId: 'task-11', taskStatus: 'PROCESSING', resultImages: [], errorMsg: null, taskStartTime: '2026-06-28T10:00:00.000Z', taskFinishTime: null, }, ], }) .mockResolvedValueOnce({ id: '91', customer: null, originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: 'airy', totalTaskCount: 1, processingTaskCount: 0, successTaskCount: 1, failedTaskCount: 0, overallStatus: 'SUCCESSES', tasks: [ { id: '1', templateId: '11', templateName: 'Layer Bob', templateCoverUrl: '', templateCategory: 'SHORT', templateSource: 'STYLIST', taskId: 'task-11', taskStatus: 'SUCCESSES', resultImages: ['https://cdn.example.com/result.png'], errorMsg: null, taskStartTime: '2026-06-28T10:00:00.000Z', taskFinishTime: '2026-06-28T10:01:00.000Z', }, ], }); renderWithRouter('/zh/simulation/result/91'); expect(await screen.findByText('Layer Bob')).toBeInTheDocument(); await vi.advanceTimersByTimeAsync(5000); expect(getSimulationById).toHaveBeenCalledTimes(2); }); ``` Extend `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`: ```tsx it('navigates to simulation create page with customerId', async () => { renderWithRouter('/zh/customers/5'); expect(await screen.findByRole('link', { name: /开始模拟|シミュレーション/i })) .toHaveAttribute('href', '/zh/simulation?customerId=5'); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx ``` Expected: FAIL because the detail page still expects the old session/job model and the customer CTA is disabled - [ ] **Step 3: Rewrite the result page and customer CTA** Rewrite `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx`: ```tsx const { simulationId = '' } = useParams(); const [detail, setDetail] = useState(null); useEffect(() => { let cancelled = false; let timer: number | undefined; const load = async () => { const data = await getSimulationById(simulationId); if (cancelled) return; setDetail(data); if (data.overallStatus === 'PROCESSING') { timer = window.setTimeout(load, 5000); } }; void load(); return () => { cancelled = true; if (timer) window.clearTimeout(timer); }; }, [simulationId]); ``` Render task cards: ```tsx {detail?.tasks.map((task) => (
{task.templateName}
{task.templateName} {task.templateSource === 'SYSTEM' ? copy.simulationResultPage.systemSource : copy.simulationResultPage.stylistSource}
{task.taskStatus}
{task.errorMsg ?

{task.errorMsg}

: null}
{task.resultImages.map((url) => ( {task.templateName} ))}
))} ``` Update `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx`: ```tsx {pageCopy.simulate} ``` Add copy keys for task counters / source labels in both locale files: ```json "simulationResultPage": { "systemSource": "系统模板", "stylistSource": "我的模板", "processing": "处理中", "success": "成功", "failed": "失败" } ``` - [ ] **Step 4: Run tests, typecheck, and lint** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx npm run typecheck npm run lint ``` Expected: ```text PASS src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx PASS src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css git commit -m "feat: add simulation detail page" ``` ## Task 8: Convert Edit And Aftercare Pages Into Undeveloped Shells **Files:** - Modify: `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx` - Modify: `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx` - Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx` **Interfaces:** - Consumes: - Existing route registration in `App.tsx` - Produces: - Stable undeveloped shell pages for edit and aftercare - [ ] **Step 1: Write failing tests for undeveloped shells** Create `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx`: ```tsx it('renders simulation edit page as undeveloped', async () => { renderWithRouter('/zh/simulation/edit'); expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument(); }); it('renders aftercare page as undeveloped', async () => { renderWithRouter('/zh/simulation/aftercare'); expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument(); }); ``` - [ ] **Step 2: Run the placeholder-page tests to verify they fail** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx ``` Expected: FAIL because both pages still render old flow-specific UI - [ ] **Step 3: Replace both pages with simple undeveloped shells** Update `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx`: ```tsx export function SimulationEditPage() { const { locale, path, copy } = usePageContext(); return ( {`Aitori Stylist | ${copy.simulationEditPage.title}`}

{copy.simulationEditPage.title}

{copy.common.undeveloped}

{copy.common.back}
); } ``` Update `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx`: ```tsx export function AftercareResultPage() { const { locale, path, copy } = usePageContext(); return ( {`Aitori Stylist | ${copy.aftercareResultPage.title}`}

{copy.aftercareResultPage.title}

{copy.common.undeveloped}

{copy.common.back}
); } ``` - [ ] **Step 4: Run placeholder tests and full frontend checks** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx npm run typecheck npm run lint ``` Expected: ```text PASS src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx git commit -m "refactor: mark simulation side pages undeveloped" ``` ## Final Verification - [ ] **Step 1: Run backend targeted tests** ```bash cd /Users/yuchen/Documents/hairstylist/backend npm test -- src/config/app.config.spec.ts src/modules/simulations/simulations.service.spec.ts --runInBand npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand ``` - [ ] **Step 2: Run frontend targeted tests** ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm test -- src/__tests__/App.test.tsx src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx npm run typecheck npm run lint ``` - [ ] **Step 3: Run backend lint and Prisma validation** ```bash cd /Users/yuchen/Documents/hairstylist/backend npm run lint npm run prisma:validate ``` - [ ] **Step 4: Manual smoke check** Run the backend and frontend locally, then verify: ```text 1. Open /zh/simulation from dashboard -> no customer selected by default 2. Open /zh/customers/:id -> click simulate -> /zh/simulation?customerId=:id preselects the customer 3. Upload an image -> upload request sends type=ai-user-origin-image 4. Switch between 我的模板 / 系统模板 -> list requests send source=STYLIST / SYSTEM 5. Submit a simulation -> navigate to /zh/simulation/result/:simulationId 6. While tasks are processing -> detail page polls backend and eventually updates counts/results 7. Open /zh/simulation/edit and /zh/simulation/aftercare -> both show undeveloped state ``` ## Self-Review - **Spec coverage:** Task 1 covers config + Prisma alignment. Tasks 2-4 cover backend template list, create flow, AI client, detail refresh, and aggregate status. Tasks 5-8 cover routes, contracts, create page, detail page, customer entry, undeveloped side pages, and cleanup of old flow-specific logic. No spec requirement is left without a task. - **Placeholder scan:** The plan does not contain `TODO`, `TBD`, “implement later”, or vague “add tests” instructions without concrete test code. Every code-changing task includes concrete file paths, snippets, and commands. - **Type consistency:** The plan uses the same DTO names and route shape throughout: - `CreateSimulationDto` - `SimulationTemplateOption` - `AiHairSimulationDetail` - `AiHairSimulationTaskDetail` - `/simulations/template-options` - `/simulations/:simulationId` - `/:locale/simulation/result/:simulationId`