Преглед на файлове

docs: add customer module implementation plan

ChenYu преди 4 месеца
родител
ревизия
a131fcfeeb
променени са 1 файла, в които са добавени 1416 реда и са изтрити 0 реда
  1. 1416 0
      docs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md

+ 1416 - 0
docs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md

@@ -0,0 +1,1416 @@
+# 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:
+
+```ts
+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:
+
+```ts
+  CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),
+```
+
+Update `backend/src/config/app.config.ts` with:
+
+```ts
+    customer: {
+      defaultAvatarUrl:
+        process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
+        'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+    },
+```
+
+Update `backend/.env.example` with:
+
+```env
+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`:
+
+```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:
+
+```sql
+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**
+
+```bash
+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`:
+
+```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`:
+
+```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`:
+
+```ts
+export class CustomerSummaryDto {
+  total!: number;
+  today!: number;
+  followUp!: number;
+  simulated!: number;
+}
+```
+
+Create `backend/src/modules/customers/customers.mapper.ts` with normalization helpers:
+
+```ts
+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:
+
+```ts
+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`:
+
+```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`:
+
+```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:
+
+```ts
+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`:
+
+```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:
+
+```ts
+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:
+
+```ts
+  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**
+
+```bash
+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`:
+
+```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:
+
+```ts
+export type PaginatedCustomerList = {
+  items: CustomerEntity[]
+  total: number
+  page: number
+  pageSize: number
+}
+```
+
+Update `frontend/src/services/api/customers.ts`:
+
+```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`:
+
+```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`:
+
+```ts
+      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`:
+
+```ts
+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:
+
+```ts
+  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:
+
+```ts
+<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`:
+
+```ts
+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`:
+
+```ts
+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**
+
+```bash
+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**
+
+```bash
+git add .
+git commit -m "chore: verify customer module create list summary"
+```