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 the customer module's first working slice by adding a real customer data model, a create API, a paginated/filterable list API, a customer summary API, and the minimum frontend integration needed for the existing customer pages to use real data.
Architecture: Keep the database aligned with the existing SQL-oriented backend by introducing a dedicated customer table and Prisma model, then map the current frontend form and filter shape into normalized backend DTOs and query enums. Backend remains the source of truth for filtering, pagination, and summary counts; frontend keeps its current page structure but stops deriving business filters locally from mock semantics.
Tech Stack: NestJS 11, Prisma, MySQL, Jest, Supertest, React 19, React Router 7, TypeScript, existing /api/v1 API envelope and JWT auth flow.
/api/v1 prefix.sub as hairstylist_id.type=avatar.https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png.page + pageSize.q fuzzy match, visit status by date range, follow status by business enum, hair length by exact match.today in customer summary must be based on next_visit_date.followUp in customer summary must be based on follow_status.simulated in customer summary must count distinct customers that have AI simulation records.Files:
backend/prisma/schema.prismabackend/prisma/migrations/<timestamp>_add_customer_table/migration.sqlbackend/src/config/env.schema.tsbackend/src/config/app.config.tsbackend/src/config/app.config.spec.tsbackend/.env.exampleInterfaces:
Customer model with fields id, hairstylistId, name, kana, phone, email, birthday, avatarUrl, contactType, lineStatus, lastVisitDate, nextVisitDate, visitCycle, hairLength, hairType, hairCondition, concern, desiredImage, formulaNote, allergyNote, privateNote, followStatus, followTiming, followMessage, createTime, updateTime, isDeletedcustomer.defaultAvatarUrlConsumes later: Prisma Customer model in CustomersService, config value customer.defaultAvatarUrl in create flow
[ ] Step 1: Write the failing config test for the default avatar URL
Update backend/src/config/app.config.spec.ts to add a new test:
it('exposes the customer default avatar url', () => {
process.env.CUSTOMER_DEFAULT_AVATAR_URL =
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png';
const config = appConfig();
expect(config.customer.defaultAvatarUrl).toBe(
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
);
});
Run: npm test -- app.config.spec.ts --runInBand
Expected: FAIL because config.customer.defaultAvatarUrl does not exist yet.
Update backend/src/config/env.schema.ts with:
CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),
Update backend/src/config/app.config.ts with:
customer: {
defaultAvatarUrl:
process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
},
Update backend/.env.example with:
CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png
Customer modelAdd normalized enums and model in backend/prisma/schema.prisma:
enum CustomerContactType {
LINE
PHONE
EMAIL
}
enum CustomerLineStatus {
LINKED
UNLINKED
INVITED
KEEP
}
enum CustomerHairLength {
SHORT
MEDIUM
LONG
}
enum CustomerFollowStatus {
NONE
NEED_FOLLOW_UP
SENT
}
model Customer {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
hairstylistId BigInt @map("hairstylist_id") @db.UnsignedBigInt
name String @db.VarChar(100)
kana String @default("") @db.VarChar(100)
phone String @db.VarChar(32)
email String @default("") @db.VarChar(100)
birthday DateTime? @db.Date
avatarUrl String @map("avatar_url") @db.VarChar(500)
contactType CustomerContactType @map("contact_type")
lineStatus CustomerLineStatus? @map("line_status")
lastVisitDate DateTime? @map("last_visit_date") @db.Date
nextVisitDate DateTime? @map("next_visit_date") @db.Date
visitCycle String @default("") @map("visit_cycle") @db.VarChar(64)
hairLength CustomerHairLength? @map("hair_length")
hairType String @default("") @map("hair_type") @db.VarChar(64)
hairCondition String @default("") @map("hair_condition") @db.VarChar(255)
concern String? @db.Text
desiredImage String? @map("desired_image") @db.Text
formulaNote String? @map("formula_note") @db.Text
allergyNote String? @map("allergy_note") @db.Text
privateNote String? @map("private_note") @db.Text
followStatus CustomerFollowStatus @default(NONE) @map("follow_status")
followTiming String @default("") @map("follow_timing") @db.VarChar(64)
followMessage String? @map("follow_message") @db.Text
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt
@@index([hairstylistId], map: "idx_hairstylist_id")
@@index([phone], map: "idx_phone")
@@index([nextVisitDate], map: "idx_next_visit_date")
@@index([followStatus], map: "idx_follow_status")
@@index([hairLength], map: "idx_hair_length")
@@index([isDeleted], map: "idx_is_deleted")
@@index([hairstylistId, isDeleted], map: "idx_hairstylist_is_deleted")
@@map("customer")
}
customer tableCreate backend/prisma/migrations/<timestamp>_add_customer_table/migration.sql with:
CREATE TABLE `customer` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`hairstylist_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(100) NOT NULL,
`kana` VARCHAR(100) NOT NULL DEFAULT '',
`phone` VARCHAR(32) NOT NULL,
`email` VARCHAR(100) NOT NULL DEFAULT '',
`birthday` DATE NULL,
`avatar_url` VARCHAR(500) NOT NULL,
`contact_type` ENUM('LINE','PHONE','EMAIL') NOT NULL DEFAULT 'LINE',
`line_status` ENUM('LINKED','UNLINKED','INVITED','KEEP') NULL,
`last_visit_date` DATE NULL,
`next_visit_date` DATE NULL,
`visit_cycle` VARCHAR(64) NOT NULL DEFAULT '',
`hair_length` ENUM('SHORT','MEDIUM','LONG') NULL,
`hair_type` VARCHAR(64) NOT NULL DEFAULT '',
`hair_condition` VARCHAR(255) NOT NULL DEFAULT '',
`concern` TEXT NULL,
`desired_image` TEXT NULL,
`formula_note` TEXT NULL,
`allergy_note` TEXT NULL,
`private_note` TEXT NULL,
`follow_status` ENUM('NONE','NEED_FOLLOW_UP','SENT') NOT NULL DEFAULT 'NONE',
`follow_timing` VARCHAR(64) NOT NULL DEFAULT '',
`follow_message` TEXT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_hairstylist_id` (`hairstylist_id`),
KEY `idx_phone` (`phone`),
KEY `idx_next_visit_date` (`next_visit_date`),
KEY `idx_follow_status` (`follow_status`),
KEY `idx_hair_length` (`hair_length`),
KEY `idx_is_deleted` (`is_deleted`),
KEY `idx_hairstylist_is_deleted` (`hairstylist_id`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客档案主表';
Run: npm test -- app.config.spec.ts --runInBand
Expected: PASS with the new customer config assertion included.
Run: npx prisma validate
Expected: The schema at prisma/schema.prisma is valid
[ ] Step 8: Commit
git add backend/prisma/schema.prisma backend/prisma/migrations backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts backend/.env.example
git commit -m "feat: add customer persistence model"
Files:
backend/src/modules/customers/customers.controller.tsbackend/src/modules/customers/customers.service.tsbackend/src/modules/customers/customers.module.tsbackend/src/modules/customers/dto/customer-upsert.dto.tsbackend/src/modules/customers/dto/list-customers-query.dto.tsbackend/src/modules/customers/dto/customer-summary.dto.tsbackend/src/modules/customers/customers.constants.tsbackend/src/modules/customers/customers.mapper.tsbackend/src/modules/customers/customers.service.spec.tsbackend/test/app.e2e-spec.tsbackend/src/app.module.ts only if route/provider wiring requires itInterfaces:
Customer model, config customer.defaultAvatarUrl, current JWT subCustomersService.create(stylistId: bigint, body: CustomerUpsertDto)CustomersService.list(stylistId: bigint, query: ListCustomersQueryDto)CustomersService.getSummary(stylistId: bigint)GET /api/v1/customers/summaryProduces: list response { items, total, page, pageSize }
[ ] Step 1: Write failing service tests for create, list query mapping, and summary
Create backend/src/modules/customers/customers.service.spec.ts:
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../prisma/prisma.service';
import { CustomersService } from './customers.service';
describe('CustomersService', () => {
let service: CustomersService;
const prisma = {
customer: {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
},
aiHairSimulation: {
findMany: jest.fn(),
},
};
beforeEach(async () => {
jest.resetAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [
CustomersService,
{ provide: PrismaService, useValue: prisma },
{
provide: ConfigService,
useValue: {
getOrThrow: jest.fn((key: string) => {
if (key === 'customer.defaultAvatarUrl') {
return 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png';
}
throw new Error(`Unexpected config key: ${key}`);
}),
},
},
],
}).compile();
service = moduleRef.get(CustomersService);
});
it('uses the default avatar url when image is omitted', async () => {
prisma.customer.create.mockResolvedValue({
id: BigInt(11),
hairstylistId: BigInt(1),
name: 'Sato Misaki',
kana: '',
phone: '090-0000-0000',
email: '',
birthday: null,
avatarUrl:
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
contactType: 'LINE',
lineStatus: null,
lastVisitDate: null,
nextVisitDate: null,
visitCycle: '',
hairLength: 'MEDIUM',
hairType: '',
hairCondition: '',
concern: null,
desiredImage: null,
formulaNote: null,
allergyNote: null,
privateNote: null,
followStatus: 'NONE',
followTiming: '',
followMessage: null,
createTime: new Date('2026-06-28T00:00:00.000Z'),
updateTime: new Date('2026-06-28T00:00:00.000Z'),
isDeleted: 0,
});
await service.create(BigInt(1), {
name: 'Sato Misaki',
phone: '090-0000-0000',
hairLength: 'Medium',
contact: 'LINE',
});
expect(prisma.customer.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
hairstylistId: BigInt(1),
avatarUrl:
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
}),
}),
);
});
it('maps list filters into prisma query conditions', async () => {
prisma.customer.findMany.mockResolvedValue([]);
prisma.customer.count.mockResolvedValue(0);
await service.list(BigInt(1), {
page: 2,
pageSize: 20,
q: '090',
visitStatus: 'TODAY',
followStatus: 'NEED_FOLLOW_UP',
hairLength: 'LONG',
});
expect(prisma.customer.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 20,
take: 20,
where: expect.objectContaining({
hairstylistId: BigInt(1),
isDeleted: 0,
followStatus: 'NEED_FOLLOW_UP',
hairLength: 'LONG',
}),
}),
);
});
it('counts summary values from customer and ai simulation data', async () => {
prisma.customer.count
.mockResolvedValueOnce(12)
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(4);
prisma.aiHairSimulation.findMany.mockResolvedValue([
{ customerId: BigInt(10) },
{ customerId: BigInt(10) },
{ customerId: BigInt(11) },
]);
const result = await service.getSummary(BigInt(1));
expect(result).toEqual({
total: 12,
today: 3,
followUp: 4,
simulated: 2,
});
});
});
Run: npm test -- customers.service.spec.ts --runInBand
Expected: FAIL because the service file, DTOs, and mapper behavior do not support these cases yet.
Create backend/src/modules/customers/customers.constants.ts:
export const CUSTOMER_DEFAULT_PAGE = 1;
export const CUSTOMER_DEFAULT_PAGE_SIZE = 20;
export const CUSTOMER_MAX_PAGE_SIZE = 100;
Create backend/src/modules/customers/dto/customer-summary.dto.ts:
export class CustomerSummaryDto {
total!: number;
today!: number;
followUp!: number;
simulated!: number;
}
Create backend/src/modules/customers/customers.mapper.ts with normalization helpers:
const HAIR_LENGTH_MAP: Record<string, 'SHORT' | 'MEDIUM' | 'LONG'> = {
Short: 'SHORT',
Medium: 'MEDIUM',
Long: 'LONG',
};
const CONTACT_TYPE_MAP: Record<string, 'LINE' | 'PHONE' | 'EMAIL'> = {
LINE: 'LINE',
Phone: 'PHONE',
Email: 'EMAIL',
};
const LINE_STATUS_MAP: Record<string, 'LINKED' | 'UNLINKED' | 'INVITED' | 'KEEP'> = {
已关联: 'LINKED',
已連携: 'LINKED',
未关联: 'UNLINKED',
未連携: 'UNLINKED',
邀请已发送: 'INVITED',
保留: 'KEEP',
};
const FOLLOW_STATUS_MAP: Record<string, 'NONE' | 'NEED_FOLLOW_UP' | 'SENT'> = {
'': 'NONE',
NONE: 'NONE',
NEED_FOLLOW_UP: 'NEED_FOLLOW_UP',
SENT: 'SENT',
需跟进: 'NEED_FOLLOW_UP',
已发送: 'SENT',
发送计划: 'NEED_FOLLOW_UP',
};
Export helpers:
export function normalizeHairLength(value?: string) {
return value ? HAIR_LENGTH_MAP[value] ?? null : null;
}
export function normalizeContactType(value?: string) {
return value ? CONTACT_TYPE_MAP[value] ?? 'LINE' : 'LINE';
}
export function normalizeLineStatus(value?: string) {
return value ? LINE_STATUS_MAP[value] ?? null : null;
}
export function normalizeFollowStatus(value?: string) {
return value == null ? 'NONE' : FOLLOW_STATUS_MAP[value] ?? 'NONE';
}
export function parseDateOnly(value?: string) {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
}
export function formatDateOnly(value: Date | null) {
return value ? value.toISOString().slice(0, 10) : undefined;
}
Update backend/src/modules/customers/dto/customer-upsert.dto.ts:
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class CustomerUpsertDto {
@IsString()
name!: string;
@IsString()
phone!: string;
@IsOptional()
@IsString()
kana?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
birthday?: string;
@IsOptional()
@IsString()
image?: string;
@IsOptional()
@IsString()
contact?: string;
@IsOptional()
@IsString()
lineStatus?: string;
@IsOptional()
@IsString()
lastVisit?: string;
@IsOptional()
@IsString()
nextVisit?: string;
@IsOptional()
@IsString()
visitCycle?: string;
@IsOptional()
@IsString()
hairLength?: string;
@IsOptional()
@IsString()
hairType?: string;
@IsOptional()
@IsString()
hairCondition?: string;
@IsOptional()
@IsString()
concern?: string;
@IsOptional()
@IsString()
desiredImage?: string;
@IsOptional()
@IsString()
formulaNote?: string;
@IsOptional()
@IsString()
allergyNote?: string;
@IsOptional()
@IsString()
privateNote?: string;
@IsOptional()
@IsString()
followStatus?: string;
@IsOptional()
@IsString()
followTiming?: string;
@IsOptional()
@IsString()
followMessage?: string;
}
Update backend/src/modules/customers/dto/list-customers-query.dto.ts:
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListCustomersQueryDto {
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(['ALL', 'TODAY', 'THIS_WEEK', 'OVER_30_DAYS_NO_VISIT'])
visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' | 'OVER_30_DAYS_NO_VISIT';
@IsOptional()
@IsIn(['ALL', 'NEED_FOLLOW_UP', 'SENT'])
followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'SENT';
@IsOptional()
@IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG'])
hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG';
}
Update backend/src/modules/customers/customers.service.ts to use PrismaService, ConfigService, and mapping helpers:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import {
CUSTOMER_DEFAULT_PAGE,
CUSTOMER_DEFAULT_PAGE_SIZE,
CUSTOMER_MAX_PAGE_SIZE,
} from './customers.constants';
import { CustomerUpsertDto } from './dto/customer-upsert.dto';
import { CustomerSummaryDto } from './dto/customer-summary.dto';
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
import {
formatDateOnly,
normalizeContactType,
normalizeFollowStatus,
normalizeHairLength,
normalizeLineStatus,
parseDateOnly,
} from './customers.mapper';
@Injectable()
export class CustomersService {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
async create(stylistId: bigint, body: CustomerUpsertDto) {
const avatarUrl =
body.image?.trim() ||
this.configService.getOrThrow<string>('customer.defaultAvatarUrl');
const created = await this.prisma.customer.create({
data: {
hairstylistId: stylistId,
name: body.name.trim(),
kana: body.kana?.trim() ?? '',
phone: body.phone.trim(),
email: body.email?.trim() ?? '',
birthday: parseDateOnly(body.birthday),
avatarUrl,
contactType: normalizeContactType(body.contact),
lineStatus: normalizeLineStatus(body.lineStatus),
lastVisitDate: parseDateOnly(body.lastVisit),
nextVisitDate: parseDateOnly(body.nextVisit),
visitCycle: body.visitCycle?.trim() ?? '',
hairLength: normalizeHairLength(body.hairLength),
hairType: body.hairType?.trim() ?? '',
hairCondition: body.hairCondition?.trim() ?? '',
concern: body.concern?.trim() || null,
desiredImage: body.desiredImage?.trim() || null,
formulaNote: body.formulaNote?.trim() || null,
allergyNote: body.allergyNote?.trim() || null,
privateNote: body.privateNote?.trim() || null,
followStatus: normalizeFollowStatus(body.followStatus),
followTiming: body.followTiming?.trim() ?? '',
followMessage: body.followMessage?.trim() || null,
},
});
return this.toCustomerEntity(created);
}
async list(stylistId: bigint, query: ListCustomersQueryDto) {
const page = query.page ?? CUSTOMER_DEFAULT_PAGE;
const pageSize = Math.min(query.pageSize ?? CUSTOMER_DEFAULT_PAGE_SIZE, CUSTOMER_MAX_PAGE_SIZE);
const skip = (page - 1) * pageSize;
const where = this.buildListWhere(stylistId, query);
const [items, total] = await this.prisma.$transaction([
this.prisma.customer.findMany({
where,
orderBy: { createTime: 'desc' },
skip,
take: pageSize,
}),
this.prisma.customer.count({ where }),
]);
return {
items: items.map((item) => this.toCustomerEntity(item)),
total,
page,
pageSize,
};
}
async getSummary(stylistId: bigint): Promise<CustomerSummaryDto> {
const today = new Date();
const startOfDay = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
const endOfDay = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1));
const [total, todayCount, followUp, simulations] = await Promise.all([
this.prisma.customer.count({ where: { hairstylistId: stylistId, isDeleted: 0 } }),
this.prisma.customer.count({
where: {
hairstylistId: stylistId,
isDeleted: 0,
nextVisitDate: { gte: startOfDay, lt: endOfDay },
},
}),
this.prisma.customer.count({
where: {
hairstylistId: stylistId,
isDeleted: 0,
followStatus: 'NEED_FOLLOW_UP',
},
}),
this.prisma.aiHairSimulation.findMany({
where: {
hairstylistId: stylistId,
isDeleted: 0,
customerId: { not: null },
},
select: { customerId: true },
}),
]);
return {
total,
today: todayCount,
followUp,
simulated: new Set(
simulations
.map((item) => item.customerId)
.filter((value): value is bigint => value != null),
).size,
};
}
private buildListWhere(stylistId: bigint, query: ListCustomersQueryDto): Prisma.CustomerWhereInput {
const where: Prisma.CustomerWhereInput = {
hairstylistId: stylistId,
isDeleted: 0,
};
if (query.q?.trim()) {
where.OR = [
{ name: { contains: query.q.trim() } },
{ phone: { contains: query.q.trim() } },
{ concern: { contains: query.q.trim() } },
{ privateNote: { contains: query.q.trim() } },
];
}
if (query.followStatus && query.followStatus !== 'ALL') {
where.followStatus = query.followStatus;
}
if (query.hairLength && query.hairLength !== 'ALL') {
where.hairLength = query.hairLength;
}
const now = new Date();
const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const endOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const dayOfWeek = startOfToday.getUTCDay();
const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(startOfToday);
startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7);
const thirtyDaysAgo = new Date(startOfToday);
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
if (query.visitStatus === 'TODAY') {
where.nextVisitDate = { gte: startOfToday, lt: endOfToday };
} else if (query.visitStatus === 'THIS_WEEK') {
where.nextVisitDate = { gte: startOfWeek, lt: endOfWeek };
} else if (query.visitStatus === 'OVER_30_DAYS_NO_VISIT') {
where.lastVisitDate = { lt: thirtyDaysAgo };
}
return where;
}
private toCustomerEntity(customer: {
id: bigint;
name: string;
kana: string;
phone: string;
email: string;
birthday: Date | null;
avatarUrl: string;
contactType: string;
lineStatus: string | null;
lastVisitDate: Date | null;
nextVisitDate: Date | null;
visitCycle: string;
hairLength: string | null;
hairType: string;
hairCondition: string;
concern: string | null;
desiredImage: string | null;
formulaNote: string | null;
allergyNote: string | null;
privateNote: string | null;
followStatus: string;
followTiming: string;
followMessage: string | null;
}) {
return {
id: customer.id.toString(),
name: customer.name,
kana: customer.kana || undefined,
phone: customer.phone,
email: customer.email || undefined,
birthday: formatDateOnly(customer.birthday),
image: customer.avatarUrl,
contact: customer.contactType,
lineStatus: customer.lineStatus ?? undefined,
lastVisit: formatDateOnly(customer.lastVisitDate),
nextVisit: formatDateOnly(customer.nextVisitDate),
visitCycle: customer.visitCycle || undefined,
hairLength: customer.hairLength ? customer.hairLength[0] + customer.hairLength.slice(1).toLowerCase() : undefined,
hairType: customer.hairType || undefined,
hairCondition: customer.hairCondition || undefined,
concern: customer.concern ?? undefined,
desiredImage: customer.desiredImage ?? undefined,
formulaNote: customer.formulaNote ?? undefined,
allergyNote: customer.allergyNote ?? undefined,
privateNote: customer.privateNote ?? undefined,
followStatus: customer.followStatus,
followTiming: customer.followTiming || undefined,
followMessage: customer.followMessage ?? undefined,
};
}
}
Update backend/src/modules/customers/customers.controller.ts:
import {
Body,
Controller,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CustomerUpsertDto } from './dto/customer-upsert.dto';
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
import { CustomersService } from './customers.service';
type AuthenticatedRequest = Request & {
user: {
sub: number;
};
};
@UseGuards(JwtAuthGuard)
@Controller('customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Get()
list(@Req() request: AuthenticatedRequest, @Query() query: ListCustomersQueryDto) {
return this.customersService.list(BigInt(request.user.sub), query);
}
@Get('summary')
summary(@Req() request: AuthenticatedRequest) {
return this.customersService.getSummary(BigInt(request.user.sub));
}
@Post()
create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
return this.customersService.create(BigInt(request.user.sub), body);
}
}
If CustomersModule does not already import PrismaModule and ConfigModule, update it:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from '../../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
imports: [AuthModule, PrismaModule, ConfigModule],
controllers: [CustomersController],
providers: [CustomersService],
})
export class CustomersModule {}
Extend backend/test/app.e2e-spec.ts with tests that use a valid JWT:
it('/api/v1/customers (POST) requires authentication', async () => {
await request(app.getHttpServer())
.post('/api/v1/customers')
.send({ name: 'Sato Misaki', phone: '090-0000-0000' })
.expect(401);
});
it('/api/v1/customers (POST) creates a customer with default avatar', async () => {
const token = await jwtService.signAsync({
sub: 1,
email: 'stylist@example.com',
username: 'Ogawa',
});
const response = await request(app.getHttpServer())
.post('/api/v1/customers')
.set('Authorization', token)
.send({
name: 'Sato Misaki',
phone: '090-0000-0000',
hairLength: 'Medium',
followStatus: 'NEED_FOLLOW_UP',
nextVisit: '2026-06-28',
})
.expect(201);
expect(response.body.ok).toBe(true);
expect(response.body.data.name).toBe('Sato Misaki');
expect(response.body.data.image).toBe(
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
);
});
it('/api/v1/customers (GET) returns paginated customers', async () => {
const token = await jwtService.signAsync({
sub: 1,
email: 'stylist@example.com',
username: 'Ogawa',
});
const response = await request(app.getHttpServer())
.get('/api/v1/customers?page=1&pageSize=20&q=Sato&hairLength=MEDIUM&followStatus=NEED_FOLLOW_UP')
.set('Authorization', token)
.expect(200);
expect(response.body.ok).toBe(true);
expect(response.body.data).toEqual({
items: expect.any(Array),
total: expect.any(Number),
page: 1,
pageSize: 20,
});
});
it('/api/v1/customers/summary (GET) returns customer summary', async () => {
const token = await jwtService.signAsync({
sub: 1,
email: 'stylist@example.com',
username: 'Ogawa',
});
const response = await request(app.getHttpServer())
.get('/api/v1/customers/summary')
.set('Authorization', token)
.expect(200);
expect(response.body.ok).toBe(true);
expect(response.body.data).toEqual({
total: expect.any(Number),
today: expect.any(Number),
followUp: expect.any(Number),
simulated: expect.any(Number),
});
});
Also ensure jwtService is available in app.e2e-spec.ts as it is in auth.e2e-spec.ts.
Run: npm test -- customers.service.spec.ts app.config.spec.ts auth.service.spec.ts --runInBand
Expected: PASS
Run: npm run test:e2e -- app.e2e-spec.ts auth.e2e-spec.ts
Expected: PASS
[ ] Step 10: Commit
git add backend/src/modules/customers backend/test/app.e2e-spec.ts backend/src/app.module.ts
git commit -m "feat: implement customer create list summary backend"
Files:
frontend/src/services/api/customers.tsfrontend/src/services/api/contracts.tsfrontend/src/pages/dashboard/customers/CustomerListPage.tsxfrontend/src/pages/dashboard/customers/CustomerFormPage.tsxfrontend/src/shared/customerForm.tsfrontend/src/services/api/__tests__/customers.test.ts if absentfrontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsxfrontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsxInterfaces:
GET /customers, POST /customers, GET /customers/summarylistCustomers(params: { page?: number; pageSize?: number; q?: string; visitStatus?: string; followStatus?: string; hairLength?: string; })Produces: frontend mapping of localized filter labels to backend enums
[ ] Step 1: Write failing frontend API tests for list query serialization and summary route
Create frontend/src/services/api/__tests__/customers.test.ts:
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { getCustomerDashboardSummary, listCustomers } from '../customers';
import { http } from '../../http/client';
vi.mock('../../http/client', () => ({
http: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
},
ApiError: class ApiError extends Error {
status = 500;
},
}));
describe('customers api', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset();
});
it('serializes pagination and filter query params', async () => {
vi.mocked(http.get).mockResolvedValue({
items: [],
total: 0,
page: 1,
pageSize: 20,
});
await listCustomers({
page: 1,
pageSize: 20,
q: 'Sato',
visitStatus: 'TODAY',
followStatus: 'NEED_FOLLOW_UP',
hairLength: 'MEDIUM',
});
expect(http.get).toHaveBeenCalledWith(
'/customers?page=1&pageSize=20&q=Sato&visitStatus=TODAY&followStatus=NEED_FOLLOW_UP&hairLength=MEDIUM',
);
});
it('uses the customer summary route', async () => {
vi.mocked(http.get).mockResolvedValue({
total: 0,
today: 0,
followUp: 0,
simulated: 0,
});
await getCustomerDashboardSummary();
expect(http.get).toHaveBeenCalledWith('/customers/summary');
});
});
Run: npm test -- customers.test.ts --runInBand
Expected: FAIL because listCustomers() currently only sends q/style and summary still points at /stylist/dashboard/customer-summary.
Update frontend/src/services/api/contracts.ts to add:
export type PaginatedCustomerList = {
items: CustomerEntity[]
total: number
page: number
pageSize: number
}
Update frontend/src/services/api/customers.ts:
import { http, ApiError } from '../http/client'
import type {
CustomerDashboardSummary,
CustomerEntity,
GetCustomerByIdParams,
PaginatedCustomerList,
} from './contracts'
export async function listCustomers(
params: {
page?: number
pageSize?: number
q?: string
visitStatus?: string
followStatus?: string
hairLength?: string
} = {},
): Promise<PaginatedCustomerList> {
const query = new URLSearchParams()
if (params.page) query.set('page', String(params.page))
if (params.pageSize) query.set('pageSize', String(params.pageSize))
if (params.q) query.set('q', params.q)
if (params.visitStatus) query.set('visitStatus', params.visitStatus)
if (params.followStatus) query.set('followStatus', params.followStatus)
if (params.hairLength) query.set('hairLength', params.hairLength)
const qs = query.toString()
return http.get<PaginatedCustomerList>(`/customers${qs ? `?${qs}` : ''}`)
}
export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
return http.get<CustomerDashboardSummary>('/customers/summary')
}
type=avatarIf uploadMedia() currently targets a legacy route, replace its usage in CustomerFormPage.tsx with the existing storage client, or update the media helper itself to hit the storage endpoint.
Preferred update in frontend/src/services/api/media.ts:
import { http } from '../http/client'
export type UploadedMedia = { url: string; objectKey: string; bucket: string; contentType: string; size: number }
export async function uploadMedia(file: File, type = 'avatar'): Promise<UploadedMedia> {
const form = new FormData()
form.append('file', file)
form.append('type', type)
return http.upload<UploadedMedia>('/storage/upload', form)
}
Then in CustomerFormPage.tsx:
if (photoFile) image = (await uploadMedia(photoFile, 'avatar')).url
Update frontend/src/pages/dashboard/customers/CustomerListPage.tsx:
const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
全部: undefined,
今天到店: 'TODAY',
本周到店: 'THIS_WEEK',
'30日以上未到店': 'OVER_30_DAYS_NO_VISIT',
全て: undefined,
本日来店: 'TODAY',
今週来店: 'THIS_WEEK',
'30日以上未来店': 'OVER_30_DAYS_NO_VISIT',
}
const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
全部: undefined,
需跟进: 'NEED_FOLLOW_UP',
已发送: 'SENT',
全て: undefined,
要フォロー: 'NEED_FOLLOW_UP',
送信済み: 'SENT',
}
const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
全部: undefined,
短发: 'SHORT',
中长发: 'MEDIUM',
长发: 'LONG',
全て: undefined,
ショート: 'SHORT',
ミディアム: 'MEDIUM',
ロング: 'LONG',
}
Replace the loader:
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
const loadCustomers = useCallback(() => {
listCustomers({
page,
pageSize,
q: query.trim() || undefined,
visitStatus: VISIT_STATUS_API_MAP[visitFilter],
followStatus: FOLLOW_STATUS_API_MAP[followFilter],
hairLength: HAIR_LENGTH_API_MAP[styleFilter],
})
.then((result) => {
setItems(result.items)
setTotal(result.total)
})
.catch(() => {
setItems([])
setTotal(0)
})
}, [page, pageSize, query, visitFilter, followFilter, styleFilter])
Remove the filteredItems useMemo, use items directly in the table, and update the result title:
<h2 className="fw-700 text-medium-gray fs-26 mb-0">
{pageCopy.resultTitle.replace('{{count}}', String(total))}
</h2>
Create frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { vi } from 'vitest'
import { CustomerListPage } from '../CustomerListPage'
vi.mock('../../../../services/api/customers', () => ({
listCustomers: vi.fn().mockResolvedValue({
items: [],
total: 42,
page: 1,
pageSize: 20,
}),
getCustomerDashboardSummary: vi.fn().mockResolvedValue({
total: 42,
today: 3,
followUp: 4,
simulated: 2,
}),
}))
test('renders backend summary and total search count', async () => {
render(
<MemoryRouter>
<CustomerListPage />
</MemoryRouter>,
)
await waitFor(() => {
expect(screen.getByText(/42/)).toBeInTheDocument()
})
})
Create frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx:
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { vi } from 'vitest'
import { CustomerFormPage } from '../CustomerFormPage'
const uploadMedia = vi.fn()
const createCustomer = vi.fn()
vi.mock('../../../../services/api/media', () => ({
uploadMedia,
}))
vi.mock('../../../../services/api/customers', () => ({
createCustomer,
getCustomerById: vi.fn(),
updateCustomer: vi.fn(),
}))
test('uploads avatar images with avatar type before create', async () => {
uploadMedia.mockResolvedValue({ url: 'https://example.com/avatar.png' })
createCustomer.mockResolvedValue({ id: '1' })
render(
<MemoryRouter>
<CustomerFormPage mode="new" />
</MemoryRouter>,
)
fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } })
fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } })
const file = new File(['avatar'], 'avatar.png', { type: 'image/png' })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
fireEvent.click(screen.getByRole('button', { name: /登记顾客/i }))
await waitFor(() => {
expect(uploadMedia).toHaveBeenCalledWith(file, 'avatar')
})
})
Run: npm test -- customers.test.ts CustomerListPage.test.tsx CustomerFormPage.test.tsx --runInBand
Expected: PASS
[ ] Step 8: Commit
git add frontend/src/services/api/customers.ts frontend/src/services/api/contracts.ts frontend/src/services/api/media.ts frontend/src/pages/dashboard/customers frontend/src/shared/customerForm.ts
git commit -m "feat: connect customer pages to real customer apis"
Files:
backend/src/modules/customers/**frontend/src/pages/dashboard/customers/**frontend/src/services/api/customers.tsInterfaces:
Produces: final verified branch state for the customer create/list/summary case
[ ] Step 1: Run backend lint
Run: npm run lint
Expected: PASS with no ESLint errors in backend/src or backend/test.
Run: npm test -- app.config.spec.ts customers.service.spec.ts auth.service.spec.ts --runInBand
Expected: PASS
Run: npm run test:e2e -- app.e2e-spec.ts auth.e2e-spec.ts
Expected: PASS
Run from frontend: npm test -- customers.test.ts CustomerListPage.test.tsx CustomerFormPage.test.tsx --runInBand
Expected: PASS
Check diagnostics for:
backend/src/modules/customers/customers.service.tsbackend/src/modules/customers/customers.controller.tsbackend/src/modules/customers/dto/customer-upsert.dto.tsbackend/src/modules/customers/dto/list-customers-query.dto.tsfrontend/src/services/api/customers.tsfrontend/src/pages/dashboard/customers/CustomerListPage.tsxfrontend/src/pages/dashboard/customers/CustomerFormPage.tsxExpected: no newly introduced diagnostics that block compilation or editing.
Confirm each spec deliverable is present:
customer table and Prisma model existtotal, today, followUp, simulatedtype=avatartotalExpected: all checked.
[ ] Step 6: Commit
git add .
git commit -m "chore: verify customer module create list summary"