2026-06-28-ai-hair-simulation.md 60 KB

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:

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:

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:

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:

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:

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:

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:

cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/config/app.config.spec.ts --runInBand
npm run prisma:validate

Expected:

PASS src/config/app.config.spec.ts
The schema at prisma/schema.prisma is valid
  • [ ] Step 6: Commit

    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:

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:

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:

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:

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:

@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:

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:

cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand

Expected:

PASS src/modules/simulations/simulations.service.spec.ts
  • [ ] Step 5: Commit

    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:

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:

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:

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:

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<string>('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:

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:

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:

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:

PASS src/modules/simulations/simulations.service.spec.ts
PASS test/simulations.e2e-spec.ts
  • [ ] Step 6: Commit

    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<AiHairSimulationDetailDto>
    • 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:

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:

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:

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:

async getResult(taskId: string): Promise<{
  status: 'processing' | 'successes' | 'failed';
  message: string;
  images: string[];
}> {
  const response = await fetch(
    `${this.configService.getOrThrow<string>('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:

private shouldRefreshTask(updateTime: Date): boolean {
  const refreshIntervalMs =
    this.configService.getOrThrow<number>('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:

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:

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:

PASS src/modules/simulations/simulations.service.spec.ts
PASS test/simulations.e2e-spec.ts
  • [ ] Step 6: Commit

    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:

it('renders simulation result detail route with simulationId param', async () => {
  window.history.pushState({}, '', '/zh/simulation/result/91');
  render(<App />);
  expect(await screen.findByText(/Aitori Stylist/i)).toBeInTheDocument();
});

Create or extend frontend/src/services/api/__tests__/integration-adapters.test.ts with:

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:

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:

simulationResultDetail: '/:locale/simulation/result/:simulationId',

Update frontend/src/App.tsx:

<Route
  path={routes.simulationResultDetail}
  element={
    <RequireAuth>
      <SimulationResultPage />
    </RequireAuth>
  }
/>

Update frontend/src/services/api/contracts.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:

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<ListResponse<SimulationTemplateOption> & { 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<AiHairSimulationDetail> {
  return http.get(`/simulations/${simulationId}`);
}

Remove obsolete exports in that file:

// delete:
// getSimulationJob
// getSimulationResults
// waitForSimulationJob
// saveSimulationResult
// adjustSimulationResult
// saveAftercareFollow
  • Step 4: Run the frontend tests

Run:

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:

PASS src/__tests__/App.test.tsx
PASS src/services/api/__tests__/integration-adapters.test.ts
  • [ ] Step 5: Commit

    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:

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:

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:

const [bindCustomer, setBindCustomer] = useState(Boolean(searchParams.get('customerId')));
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(searchParams.get('customerId') ?? '');
const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST');
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
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:

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:

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

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

cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
npm run typecheck

Expected:

PASS src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
  • [ ] Step 5: Commit

    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:

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:

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:

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:

const { simulationId = '' } = useParams();
const [detail, setDetail] = useState<AiHairSimulationDetail | null>(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:

{detail?.tasks.map((task) => (
  <article key={task.id} className="simulation-task-card">
    <div className="simulation-task-header">
      <img src={task.templateCoverUrl || '/images/aitori-simulation-result.png'} alt={task.templateName} />
      <div>
        <strong>{task.templateName}</strong>
        <span>{task.templateSource === 'SYSTEM' ? copy.simulationResultPage.systemSource : copy.simulationResultPage.stylistSource}</span>
      </div>
      <span className={`template-status-badge is-${task.taskStatus.toLowerCase()}`}>
        {task.taskStatus}
      </span>
    </div>
    {task.errorMsg ? <p role="alert">{task.errorMsg}</p> : null}
    <div className="simulation-task-images">
      {task.resultImages.map((url) => (
        <img key={url} src={url} alt={task.templateName} />
      ))}
    </div>
  </article>
))}

Update frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx:

<Link
  to={path(`/simulation?customerId=${customer.id}`)}
  className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn"
>
  <i className="feather icon-feather-camera"></i>
  {pageCopy.simulate}
</Link>

Add copy keys for task counters / source labels in both locale files:

"simulationResultPage": {
  "systemSource": "系统模板",
  "stylistSource": "我的模板",
  "processing": "处理中",
  "success": "成功",
  "failed": "失败"
}
  • Step 4: Run tests, typecheck, and lint

Run:

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:

PASS src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
PASS src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
  • [ ] Step 5: Commit

    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:

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:

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:

export function SimulationEditPage() {
  const { locale, path, copy } = usePageContext();
  return (
    <DashboardLayout>
      <Helmet>
        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
        <title>{`Aitori Stylist | ${copy.simulationEditPage.title}`}</title>
      </Helmet>
      <section className="pt-0">
        <div className="container">
          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
            <h1>{copy.simulationEditPage.title}</h1>
            <p>{copy.common.undeveloped}</p>
            <Link to={path('/simulation')} className="btn btn-base-color fw-700">
              {copy.common.back}
            </Link>
          </div>
        </div>
      </section>
    </DashboardLayout>
  );
}

Update frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx:

export function AftercareResultPage() {
  const { locale, path, copy } = usePageContext();
  return (
    <DashboardLayout>
      <Helmet>
        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
        <title>{`Aitori Stylist | ${copy.aftercareResultPage.title}`}</title>
      </Helmet>
      <section className="pt-0">
        <div className="container">
          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
            <h1>{copy.aftercareResultPage.title}</h1>
            <p>{copy.common.undeveloped}</p>
            <Link to={path('/simulation')} className="btn btn-base-color fw-700">
              {copy.common.back}
            </Link>
          </div>
        </div>
      </section>
    </DashboardLayout>
  );
}
  • Step 4: Run placeholder tests and full frontend checks

Run:

cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
npm run typecheck
npm run lint

Expected:

PASS src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
  • [ ] Step 5: Commit

    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

    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

    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

    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:

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