Stores Schema

Source of truth:

  • Postgres: callytics-common/src/db/schema/rc-stores.ts · store-config.ts · staff.ts · user-stores.ts (verified 2026-06-15)
  • DynamoDB: studio-website-monorepo/apps/api/infra/lib/stack.ts (CDK Table) + apps/api/src/types/domain.ts (item shape) + apps/api/src/repositories/stores.ts (writers)

隔离架构: 唯一隔离键 store_id (UUID) — 详见 Store-Level 隔离。Postgres rc_stores.id 与 DynamoDB StoresV2.storeId 是同一个 UUID,跨数据库一致。 不在本文档: 多门店聚合/激活/通知邮箱的运行时计算口径在 stores-calculations.md;页面控件与卡片展示在 stores-display.md


1. 表说明与分工

Stores 域 6 张表分两组:Postgres(callytics-common Drizzle schema)+ DynamoDB(legacy + 跨服务共享)。

位置谁维护用途
rc_storesPostgrescode(studio-api syncStoresToNeon())镜像 RC Site 结构。全系统隔离键 store_id = rc_stores.id
rc_store_phonesPostgrescode(随 rc_stores sync 全量替换)phone → store 物理映射(store 级过滤的核心查询)
store_configPostgresuser(studio-api PATCH /v3/setup/stores/:id)用户配置(alias / 激活 / leadEmails / pricing)。RC sync 永不覆盖
staffPostgrescode(studio-api syncStaffToNeon(),replace pattern)员工身份(关闭任务下拉、AI 识别员工名、V2 PIN 登录)
store_membersPostgresuser(studio-api access-management routes)多门店共享访问(non-owner 才记录,owner 隐含在 rc_stores.user_id)
StoresV2 (DDB)DynamoDBcode+user(studio-api stores.ts repository)跨服务共享:storeId → businessConfig 查询(callytics-infrastructure 多个 Lambda 通过 IAM 读),含 staff / schedule / pricing 全量配置

⚠️ Postgres rc_stores 与 DDB StoresV2 共享同一个 storeId UUID。Postgres 表是 v3 setup/dashboard 数据源;DDB 表是 callytics Lambda 跨服务读 businessConfig 的入口。改隔离逻辑时两边都要看。

写入者列说明:

  • code = 自动写入(RC sync / Lambda pipeline / studio-api 系统级操作)
  • user = 用户通过 UI 写入

2. Postgres rc_stores — 门店主表(RC 同步)

由 RingCentral Site 结构自动创建。syncStoresToNeon() 在 OAuth 连接和手动 Sync 时按 (user_id, connection_id, rc_site_id) upsert。

#Field (TS)SQL 列TypeNullableEnum / 默认Writer用途
1ididuuid (PK)NOT NULLdefaultRandom()code门店唯一标识。全系统隔离键 store_id 就是它
2userIduser_idtextNOT NULLcodeCognito userId — 门店所有者(owner 隐含)
3connectionIdconnection_idtextNOT NULLcodeFK → DDB UserConnections.connectionId
4providerAccountIdprovider_account_idtextNOT NULLcodeRC account ID
5rcSiteIdrc_site_idtextNOT NULLcodeRC Extension List API 的 site.id,在单个 RC account 内唯一
6namenametextNOT NULLcodeRC site.name,从 RC 派生的展示名
7timezonetimezonetextNULLcodeIANA 时区,取自 RC SiteInfo.regionalSettings.timezone.name(e.g. US/Eastern)
8typetypetextNULLstore | group,default storecode门店类型:store(实体)/ group(虚拟分组)。对齐 DDB StoresV2.type
9franchisefranchisetextNULLcode品牌标识(e.g. orangeTheory)。并非所有门店都属于某品牌
10createdAtcreated_attimestamptzNOT NULLdefaultNow()code创建时间
11syncedAtsynced_attimestamptzNOT NULLdefaultNow()code最近一次 RC sync 时间

索引

索引作用
uq_rc_stores_user_conn_site(user_id, connection_id, rc_site_id) UNIQUE同一用户在同一 RC 连接下,同 site 只一条
idx_rc_stores_useruser_id按 user 查我的所有门店(setup / dashboard 主查询)
idx_rc_stores_providerprovider_account_id按 RC account 反查

为什么 3 列联合唯一: 一个用户可有多个 RC 连接(不同 providerAccountId),不同连接可能共用同一个合成 siteId(e.g. 单站点账户都叫 main)。


3. Postgres rc_store_phones — 电话 ↔ 门店映射(RC 同步)

phone→store 的物理基础。studio-api 用它做 store-level filtering(把通话号码映射到具体门店)。从 RC Extension→Site 关系派生,每次 sync 全量替换(按 store DELETE + INSERT)。无用户编辑入口 — 改门店或电话要去 RingCentral 改完再 re-sync。

#Field (TS)SQL 列TypeNullableEnum / 默认Writer用途
1storeIdstore_iduuidNOT NULLFK→rc_stores.id ON DELETE CASCADEcode所属门店
2phoneNumberphone_numbertextNOT NULLcodeE.164 格式(e.g. +15551234567)
3extensionIdextension_idtextNULLcode该电话所属的 RC extension ID
4extensionNameextension_nametextNULLcodeRC extension 展示名(e.g. Front Desk / Ardmore OTF)
5phoneTypephone_typetextNOT NULLMAIN | DIRECT | TOLLFREEcode从 RC usageType 映射

索引

索引作用
uq_rc_store_phones_store_phone(store_id, phone_number) UNIQUE一店一号去重
idx_rc_store_phones_numberphone_number按号码反查所属门店(store-level 过滤的核心查询)

4. Postgres store_config — 门店配置(用户设置)

用户控制的门店配置,与 rc_stores 分离,RC sync 永不覆盖。1:1 关系,首次写配置时惰性创建。

#Field (TS)SQL 列TypeNullableEnum / 默认Writer用途
1storeIdstore_iduuid (PK)NOT NULLFK→rc_stores.id ON DELETE CASCADEcode关联门店。RC site 被删时配置自动清理
2aliasaliastextNULLuser用户自定义展示名。NULL 时回退用 rc_stores.name
3activatedAtactivated_attimestamptzNULLuser门店"加入"用户活跃工作区(侧栏 / Dashboard)的时间戳。NULL = 未激活,非空 = 在该时刻激活
4leadEmailslead_emailstext[]NULLdefault []user门店级 Lead 通知邮箱列表
5pricingpricingjsonbNULLdefault {}user定价配置(intro offers / class packs / membership tiers)
6updatedAtupdated_attimestamptzNOT NULLdefaultNow(),$onUpdatecode最后更新时间

activatedAt 是 timestamp 不是 boolean: 它决定门店是否出现在用户的活跃工作区。Dashboard 的 multi-store 聚合用 WHERE sc.activated_at IS NOT NULL 过滤 — 只统计已激活门店,同时免费拿到激活时间线。(Gemini 设计建议,2026-04-30)


5. Postgres staff — 员工身份

V1.5:Close Task dialog 的 "Who are you?" 下拉。V2:完整员工管理(PIN 登录、角色权限)。Isolation key = store_id (NOT NULL);account_id 作为 legacy 审计字段保留。

#Field (TS)SQL 列TypeNullableEnum / 默认Writer用途
1ididuuid (PK)NOT NULLdefaultRandom()code员工唯一标识
2franchiseIdfranchise_idtextNOT NULLcode品牌标识(e.g. orangeTheory),保留 NOT NULL 给未来 brand-level 查询
3accountIdaccount_idtextNULLcodeRC providerAccountId。已被 storeId 取代为隔离键,保留为审计列
4storeIdstore_idtextNOT NULLcodeDynamoDB StoresV2 store UUID — 终态隔离键
5namenametextNOT NULLcode员工姓名
6pinpintextNULLcodeV2 存 bcrypt hash,绝不存明文
7roleroletextNULLstaff | managercodeV1.5 为 NULL;V2 用于权限控制
8isActiveis_activebooleanNOT NULLdefault truecode软删除。停用员工保留 FK 完整性,用于历史归因
9createdAtcreated_attimestamptzNOT NULLdefaultNow()code创建时间
10updatedAtupdated_attimestamptzNOT NULLdefaultNow(),$onUpdatecode最后更新时间

索引

索引作用
idx_staff_store_idstore_id主查询:按 store 列出员工
uq_staff_id_store(id, store_id) UNIQUE给 V2 复合 FK 用:tasks(closed_by_staff_id, store_id) → staff(id, store_id) 强制 store 一致

Writers: studio-api syncStaffToNeon()(replace pattern,store 级全量替换)。 Readers: callytics-infrastructure transcribe-processor(keywords)/ ai-analysis-processor(staff name 识别)/ studio-api GET store(merge)。


6. Postgres store_members — 多门店共享访问

替代 DDB orangetheory-UserStore-{env} 给 v3 用。只记 non-owner(EDITOR / VIEWER);owner 隐含在 rc_stores.user_id,不在此表冗余存储。

#Field (TS)SQL 列TypeNullableEnum / 默认Writer用途
1userIduser_idtextNOT NULLPK 复合userCognito user ID,接收共享访问的人
2storeIdstore_iduuidNOT NULLPK 复合,FK→rc_stores.id ON DELETE CASCADEuser被授权访问的门店
3roleroletextNOT NULLEDITOR | VIEWER(CHECK 强制)— OWNER 不在此表user角色
4grantedBygranted_bytextNOT NULLuser授权者 userId(通常是 owner)
5createdAtcreated_attimestamptzNOT NULLdefaultNow()code创建时间
6updatedAtupdated_attimestamptzNOT NULLdefaultNow(),$onUpdatecode最后更新时间

约束 / 索引

名称类型作用
(user_id, store_id)PRIMARY KEY 复合同一用户在同一门店只一条共享记录
idx_store_members_store_idINDEX按 store 查所有共享成员(grant/revoke UI)
chk_store_members_roleCHECK role IN ('EDITOR', 'VIEWER')数据库层杜绝 OWNER 写入此表

v3 授权流程(shared.ts:getStoreContext):

  1. rc_stores.user_id === userId → OWNER(零查询)
  2. store_members WHERE user_id = ? AND store_id = ? → EDITOR / VIEWER
  3. 都不是 → ForbiddenError

7. DynamoDB orangetheory-StoresV2-{env} — 跨服务 store + businessConfig

为什么独立于 Postgres: callytics-infrastructure 多个 Lambda(transcribe-processor / ai-analysis-processor / contacts-analyzer)需要 storeId → businessConfig 低延迟查询。这张 DDB 表是跨服务共享入口,IAM cross-account 授权访问(见 iam-stack.ts)。Postgres rc_stores + store_config 是 v3 setup / dashboard 视图,DDB StoresV2 是 Lambda runtime view。两者通过同一 storeId UUID 对齐。

Table 配置(studio-website-monorepo/apps/api/infra/lib/stack.ts:240-254)

配置
TableNameorangetheory-StoresV2-{env} (env ∈ test / pre / preprod / prod)
Partition KeystoreId (STRING)
Sort Key无(单键表)
GSI: userId-indexPK userId (STRING) + SK createdAt (STRING),ProjectionType ALL
Billing ModePAY_PER_REQUEST
PITRprod 开启
Deletion Protectionprod 开启
Removal Policyprod RETAIN / 非 prod DESTROY

Item shape — 全字段平铺(Store / BusinessConfig / StaffMember / DefaultDaySchedule / DateSchedule interfaces,apps/api/src/types/domain.ts:89-150)

下表把嵌套对象按 Path 列展平,reviewer 一张表看完所有字段。Path 列约定:

  • 单段(无 .)= 顶层 Store 字段(e.g. storeId / name)
  • businessConfig.X = 顶层 businessConfig 下的二级字段(单值或数组容器)
  • businessConfig.staff[].X = staff 数组每个 StaffMember 元素的字段
  • businessConfig.defaultSchedule[].X = defaultSchedule 数组每个 DefaultDaySchedule 元素的字段
  • businessConfig.schedules[].X = schedules 数组每个 DateSchedule 元素的字段
  • businessConfig.pricing.classPacks[].X 同理(嵌套 2 层)
#PathTypeRequired | OptionalNotes
1storeIdstring (PK)RequiredUUID,与 Postgres rc_stores.id 相同;表的 partition key
2userIdstringRequiredOwner Cognito userId;GSI userId-index 的 PK
3providerAccountIdstringRequired绑定的 RC account ID
4connectionIdstringRequiredFK → DDB UserConnections.connectionId
5namestringRequired门店名
6typeStoreTypeRequiredEnum 'store' | 'group';'store' = 实体门店,'group' = 虚拟分组
7franchisestringRequired品牌标识(e.g. orangeTheory)
8timezonestring | nullRequired(可为 null)IANA 时区,可显式 null
9leadEmailsstring[]Required门店级 Lead 通知邮箱;与 Postgres store_config.leadEmails 同步
10businessConfigBusinessConfig 对象Required(container)容器字段;子字段见下方 13–40
11createdAtstring (ISO)Required创建时间
12updatedAtstring (ISO)Required最后更新时间
13businessConfig.staffstring | StaffMember[]OptionalLegacy: newline-separated 姓名字符串;新格式: 结构化数组(元素字段见 17–23)
14businessConfig.defaultScheduleDefaultDaySchedule[]Optional默认周课表数组(元素字段见 24–28)
15businessConfig.schedulesDateSchedule[]Optional单日 override 课表数组(元素字段见 29–34)
16businessConfig.pricingobjectOptional定价容器;子字段见 35–40
17businessConfig.staff[].idstringRequired员工 ID(数组元素内)
18businessConfig.staff[].namestringRequired员工姓名
19businessConfig.staff[].emailstringOptional邮箱
20businessConfig.staff[].phoneExtensionstringOptional分机号
21businessConfig.staff[].roleStaffRoleRequiredEnum 'general' | 'front_desk' | 'head_coach' | 'manager'
22businessConfig.staff[].colorstringRequiredTailwind color class(e.g. green-500)
23businessConfig.staff[].isActivebooleanRequired是否在职
24businessConfig.defaultSchedule[].dayOfWeeknumberRequired0=Sun, 1=Mon, ..., 6=Sat
25businessConfig.defaultSchedule[].isOpenbooleanRequired当天是否营业
26businessConfig.defaultSchedule[].openTimestringOptionalHH:mm 格式
27businessConfig.defaultSchedule[].closeTimestringOptionalHH:mm 格式
28businessConfig.defaultSchedule[].staffIdsstring[]Required当天值班员工 ID(关联 staff[].id)
29businessConfig.schedules[].datestringRequiredYYYY-MM-DD 格式(单日 override)
30businessConfig.schedules[].isOpenbooleanRequired当天是否营业
31businessConfig.schedules[].openTimestringOptionalHH:mm 格式
32businessConfig.schedules[].closeTimestringOptionalHH:mm 格式
33businessConfig.schedules[].staffIdsstring[]Required当天值班员工 ID
34businessConfig.schedules[].notestringOptionale.g. Holiday / Special event
35businessConfig.pricing.introOfferstringOptional体验价文案
36businessConfig.pricing.classPacksArray<{ name, numClasses, price }>Optional课包列表;元素字段:name: string / numClasses: number / price: number
37businessConfig.pricing.membershipTiersArray<{ name, classesPerMonth, monthlyPrice }>Optional会员等级;元素字段:name: string / classesPerMonth: string / monthlyPrice: number
38businessConfig.pricing.lateCancelFeenumberOptional临取消费
39businessConfig.pricing.hrmPurchasenumberOptional心率带购买价
40businessConfig.pricing.hrmRentalMonthlynumberOptional心率带月租

⚠️ Update 行为踩坑提醒: updateStore()(apps/api/src/repositories/stores.ts:141-249)对 businessConfig字段级合并 — 只更新发送来的二级子字段(e.g. 只更新 staff 不会清空 schedules / pricing),实现是 DDB SET businessConfig.staff = :val 这种 nested path update。如果 businessConfig 整个字段本身还不存在,DDB 会拒绝 nested SET 抛 ValidationException: document path,此时降级为整对象 SET(stores.ts:213-247 的 catch 块处理)。

⚠️ DDB businessConfig.staff[] 与 Postgres staff 表是两份独立数据:前者给 calendar / scheduling 用,后者给 AI 识别员工 + Close Task 归因用。改员工时两边都要写。


8. 跨表关系图

┌──────────────────────────────────────────────────────────────┐
│                     storeId (UUID) — 全系统隔离键              │
└──────────────────────────────────────────────────────────────┘

       ┌──────────────────────┼──────────────────────┐
       │                      │                      │
   Postgres              Postgres              DynamoDB
       │                      │                      │
  ┌────▼─────┐         ┌──────▼──────┐         ┌─────▼──────┐
  │rc_stores │◄────FK──┤store_config │         │ StoresV2   │
  │  .id     │         │ .store_id   │         │ .storeId   │
  └────┬─────┘         └─────────────┘         │            │
       │                                       │ + GSI      │
       │ FK CASCADE                            │  userId-   │
       │                                       │  index     │
   ┌───▼──────────┐    ┌──────────────┐        └────────────┘
   │rc_store_     │    │store_members │
   │ phones       │    │  .store_id   │
   │ .store_id    │    └──────────────┘
   └──────────────┘
                       ┌──────────────┐
   (staff.store_id 是  │   staff      │
    text,不强制 FK,    │  .store_id   │
    跨服务共用)         └──────────────┘

关键不变式:

  1. Postgres rc_stores.id ≡ DDB StoresV2.storeId — 同一个 UUID,跨数据库表达同一个门店
  2. store_id 在所有跨表查询中都必须出现(contacts / calls / tasks 全部带 store_id guard,见 calls-schema.md / tasks-schema.md / contacts-schema.md)
  3. owner 不在 store_members 表里 — owner = rc_stores.user_id,授权流程先查 owner 命中就返回
  4. DDB businessConfig.staff[] 与 Postgres staff 表是两份独立数据 — 用途不同,改一边不会自动同步另一边

9. Appendix A — Re-verification commands

2026-06-01 verified。文档 stale(>30 天)时重跑下方命令并比对。

# 1. Postgres 4 张 stores 域表
for f in rc-stores store-config staff user-stores; do
  echo "=== $f ==="
  grep -E "pgTable|text\(|integer\(|timestamp\(|uuid\(|boolean\(|jsonb\(|notNull\(|references\(|primaryKey\(|uniqueIndex\(|index\(|check\(" \
    ../../../../../callytics-common/src/db/schema/$f.ts
done

# 2. DDB StoresV2 CDK 定义
grep -n -A 15 "StoresV2-\${environment}\b" \
  ../../../../../studio-website-monorepo/apps/api/infra/lib/stack.ts

# 3. DDB Store item shape
grep -n -A 20 "^export interface Store\b\|^export interface BusinessConfig\b\|^export interface StaffMember\b\|^export interface DefaultDaySchedule\b" \
  ../../../../../studio-website-monorepo/apps/api/src/types/domain.ts

# 4. Task pipeline design 是否动 stores 域
grep -iE "rc_stores|store_config|staff\.ts|store_members|StoresV2" \
  ../../tasks-feature/design/task-pipeline-deliverable-codex.md
# 期望: 只 reference storeId 做隔离 guard,不动 schema

# 5. Unified pipeline 是否动 stores 域
grep -iE "rc_stores|store_config|staff\.ts|store_members|StoresV2" \
  ../../unified-pipeline/unified-pipeline-final.md
# 期望: 只 reference storeId 做隔离 guard,不动 schema

10. Cross-References

  • 隔离架构(全 repo 看哪): docs/system-design/store-level-isolation.md
  • 多门店运行时计算口径: stores-calculations.md
  • 页面展示规范: stores-display.md
  • 上游表(都带 storeId 做 store guard):
  • Unified pipeline: ../unified-pipeline/unified-pipeline-final.md
  • Live schema:
    • Postgres: callytics-common/src/db/schema/rc-stores.ts · store-config.ts · staff.ts · user-stores.ts
    • DynamoDB CDK: studio-website-monorepo/apps/api/infra/lib/stack.ts
    • DDB item shape: studio-website-monorepo/apps/api/src/types/domain.ts
    • DDB repository: studio-website-monorepo/apps/api/src/repositories/stores.ts