2026-06-28-customer-module-create-list-summary.md 43 KB

Customer Module Create/List/Summary 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 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.

Global Constraints

  • API routing must continue to use the /api/v1 prefix.
  • All customer data must be isolated by the current JWT user sub as hairstylist_id.
  • This case implements only customer create, customer paginated list with filters, and customer summary.
  • Customer detail, customer update, records aggregation, reminders, and other customer subfeatures remain out of scope.
  • Customer avatar upload must reuse the existing storage upload capability and use type=avatar.
  • Customer avatar is optional; when omitted, the backend must persist the exact default URL https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png.
  • Database values must use normalized backend values, not localized Chinese/Japanese display copy.
  • Customer list pagination must use page + pageSize.
  • Customer list filtering semantics are: 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.
  • The implementation must keep a zero-mock business path: real backend behavior, real DB writes, real frontend API integration.

Task 1: Add Customer Persistence Model And Configurable Defaults

Files:

  • Modify: backend/prisma/schema.prisma
  • Create: backend/prisma/migrations/<timestamp>_add_customer_table/migration.sql
  • Modify: backend/src/config/env.schema.ts
  • Modify: backend/src/config/app.config.ts
  • Modify: backend/src/config/app.config.spec.ts
  • Modify: backend/.env.example

Interfaces:

  • Produces: Prisma 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, isDeleted
  • Produces: config value customer.defaultAvatarUrl
  • Consumes 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',
  );
});
  • Step 2: Run the config test to verify it fails

Run: npm test -- app.config.spec.ts --runInBand

Expected: FAIL because config.customer.defaultAvatarUrl does not exist yet.

  • Step 3: Add environment schema and app config support for the default avatar URL

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
  • Step 4: Extend Prisma schema with the Customer model

Add 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")
}
  • Step 5: Create the SQL migration for the customer table

Create 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='顾客档案主表';
  • Step 6: Run the config test to verify it passes

Run: npm test -- app.config.spec.ts --runInBand

Expected: PASS with the new customer config assertion included.

  • Step 7: Run Prisma validation

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"
    

Task 2: Implement Customer Create/List/Summary Backend With Tests

Files:

  • Modify: backend/src/modules/customers/customers.controller.ts
  • Modify: backend/src/modules/customers/customers.service.ts
  • Modify: backend/src/modules/customers/customers.module.ts
  • Modify: backend/src/modules/customers/dto/customer-upsert.dto.ts
  • Modify: backend/src/modules/customers/dto/list-customers-query.dto.ts
  • Create: backend/src/modules/customers/dto/customer-summary.dto.ts
  • Create: backend/src/modules/customers/customers.constants.ts
  • Create: backend/src/modules/customers/customers.mapper.ts
  • Create: backend/src/modules/customers/customers.service.spec.ts
  • Modify: backend/test/app.e2e-spec.ts
  • Modify: backend/src/app.module.ts only if route/provider wiring requires it

Interfaces:

  • Consumes: Prisma Customer model, config customer.defaultAvatarUrl, current JWT sub
  • Produces: CustomersService.create(stylistId: bigint, body: CustomerUpsertDto)
  • Produces: CustomersService.list(stylistId: bigint, query: ListCustomersQueryDto)
  • Produces: CustomersService.getSummary(stylistId: bigint)
  • Produces: GET /api/v1/customers/summary
  • Produces: 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,
    });
  });
});
  • Step 2: Run the service test to verify it fails

Run: npm test -- customers.service.spec.ts --runInBand

Expected: FAIL because the service file, DTOs, and mapper behavior do not support these cases yet.

  • Step 3: Add DTOs, constants, and mapper helpers

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;
}
  • Step 4: Expand DTO validation for create/list requests

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';
}
  • Step 5: Implement service create/list/summary behavior

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,
    };
  }
}
  • Step 6: Wire controller routes to stylist-scoped service methods

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);
  }
}
  • Step 7: Ensure module provides Prisma dependencies

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 {}
  • Step 8: Add e2e coverage for unauthorized access, create, list, and summary

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.

  • Step 9: Run backend tests to verify they pass

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"
    

Task 3: Adapt Frontend Customer Pages To Real Customer APIs

Files:

  • Modify: frontend/src/services/api/customers.ts
  • Modify: frontend/src/services/api/contracts.ts
  • Modify: frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  • Modify: frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
  • Modify: frontend/src/shared/customerForm.ts
  • Create or Modify: frontend/src/services/api/__tests__/customers.test.ts if absent
  • Create or Modify: frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx
  • Create or Modify: frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

Interfaces:

  • Consumes: backend GET /customers, POST /customers, GET /customers/summary
  • Produces: listCustomers(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');
  });
});
  • Step 2: Run the frontend API test to verify it fails

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.

  • Step 3: Expand frontend customer contracts and API helpers

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')
}
  • Step 4: Adapt customer form avatar upload to storage upload with type=avatar

If 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
  • Step 5: Move customer list filters from local derivation to backend params

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>
  • Step 6: Add lightweight customer page tests

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')
  })
})
  • Step 7: Run frontend tests to verify they pass

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"
    

Task 4: Verify The Full Customer Slice And Clean Up

Files:

  • Modify: any files needed to fix failing diagnostics or test regressions from prior tasks
  • Check: backend/src/modules/customers/**
  • Check: frontend/src/pages/dashboard/customers/**
  • Check: frontend/src/services/api/customers.ts

Interfaces:

  • Consumes: all completed backend and frontend customer interfaces
  • 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.

  • Step 2: Run backend targeted tests

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

  • Step 3: Run frontend targeted tests

Run from frontend: npm test -- customers.test.ts CustomerListPage.test.tsx CustomerFormPage.test.tsx --runInBand

Expected: PASS

  • Step 4: Run diagnostics on touched customer files

Check diagnostics for:

  • backend/src/modules/customers/customers.service.ts
  • backend/src/modules/customers/customers.controller.ts
  • backend/src/modules/customers/dto/customer-upsert.dto.ts
  • backend/src/modules/customers/dto/list-customers-query.dto.ts
  • frontend/src/services/api/customers.ts
  • frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  • frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

Expected: no newly introduced diagnostics that block compilation or editing.

  • Step 5: Manually verify spec coverage

Confirm each spec deliverable is present:

  • customer table and Prisma model exist
  • customer create persists default avatar when omitted
  • customer list uses backend pagination and backend filters
  • customer summary returns total, today, followUp, simulated
  • frontend customer form uploads avatar via type=avatar
  • frontend customer list result count uses backend total

Expected: all checked.

  • [ ] Step 6: Commit

    git add .
    git commit -m "chore: verify customer module create list summary"