Quellcode durchsuchen

feat: add customer persistence model

ChenYu vor 4 Monaten
Ursprung
Commit
bf8ff006b6

+ 1 - 0
backend/.env.example

@@ -11,3 +11,4 @@ GCS_BUCKET=aitori-public
 GCS_PROJECT_ID=app-aitori-jp
 GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com
 GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n
+CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png

+ 37 - 0
backend/prisma/migrations/20260628120000_add_customer_table/migration.sql

@@ -0,0 +1,37 @@
+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='顾客档案主表';

+ 64 - 0
backend/prisma/schema.prisma

@@ -21,6 +21,31 @@ enum TemplateTaskStatus {
   FAILED
 }
 
+enum CustomerContactType {
+  LINE
+  PHONE
+  EMAIL
+}
+
+enum CustomerLineStatus {
+  LINKED
+  UNLINKED
+  INVITED
+  KEEP
+}
+
+enum CustomerHairLength {
+  SHORT
+  MEDIUM
+  LONG
+}
+
+enum CustomerFollowStatus {
+  NONE
+  NEED_FOLLOW_UP
+  SENT
+}
+
 model HairstylistUser {
   id         BigInt   @id @default(autoincrement()) @db.UnsignedBigInt
   username   String   @db.VarChar(50)
@@ -33,6 +58,45 @@ model HairstylistUser {
   @@map("hairstylist_user")
 }
 
+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 @default(LINE) @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")
+}
+
 model CustomerTreatmentRecord {
   id              BigInt   @id @default(autoincrement()) @db.UnsignedBigInt
   customerId      BigInt   @map("customer_id") @db.UnsignedBigInt

+ 18 - 0
backend/src/config/app.config.spec.ts

@@ -17,6 +17,8 @@ describe('appConfig storage', () => {
       GCS_PROJECT_ID: 'app-aitori-jp',
       GCS_CLIENT_EMAIL: 'oss-227@app-aitori-jp.iam.gserviceaccount.com',
       GCS_PRIVATE_KEY: 'line1\\nline2',
+      CUSTOMER_DEFAULT_AVATAR_URL:
+        'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     });
 
     expect(parsed.STORAGE_DRIVER).toBe('gcs');
@@ -27,11 +29,27 @@ describe('appConfig storage', () => {
     process.env.GCS_PROJECT_ID = 'app-aitori-jp';
     process.env.GCS_CLIENT_EMAIL = 'oss-227@app-aitori-jp.iam.gserviceaccount.com';
     process.env.GCS_PRIVATE_KEY = 'line1\\nline2';
+    process.env.CUSTOMER_DEFAULT_AVATAR_URL =
+      'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png';
 
     const config = appConfig();
 
     expect(config.storage.driver).toBe('gcs');
     expect(config.storage.gcs.bucket).toBe('aitori-public');
     expect(config.storage.gcs.privateKey).toBe('line1\nline2');
+    expect(config.customer.defaultAvatarUrl).toBe(
+      'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+    );
+  });
+
+  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',
+    );
   });
 });

+ 5 - 0
backend/src/config/app.config.ts

@@ -22,4 +22,9 @@ export default () => ({
       privateKey: normalizePrivateKey(process.env.GCS_PRIVATE_KEY ?? ''),
     },
   },
+  customer: {
+    defaultAvatarUrl:
+      process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
+      'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+  },
 });

+ 1 - 0
backend/src/config/env.schema.ts

@@ -14,6 +14,7 @@ export const envSchema = z.object({
   GCS_PROJECT_ID: z.string().min(1),
   GCS_CLIENT_EMAIL: z.string().email(),
   GCS_PRIVATE_KEY: z.string().min(1),
+  CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),
 });
 
 export function validateEnv(config: Record<string, unknown>) {