From 6f0b7e0b9b5fa2d6e43afed5ca8f98a312556dfe Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 28 Apr 2026 20:53:00 +0800 Subject: [PATCH] feat(auth): delete account (#1756) --- apps/server/docs/ai-context/README.md | 4 + .../docs/ai-context/account-deletion.md | 184 ++ .../verifications/account-deletion.md | 131 + .../drizzle/0009_perpetual_lilandra.sql | 29 + apps/server/drizzle/meta/0009_snapshot.json | 2755 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 9 +- apps/server/src/app.test.ts | 1 + apps/server/src/app.ts | 76 +- apps/server/src/libs/auth.ts | 55 +- apps/server/src/routes/stripe/route.test.ts | 2 + apps/server/src/schemas/characters.ts | 8 +- apps/server/src/schemas/flux-transaction.ts | 7 +- apps/server/src/schemas/flux.ts | 8 +- apps/server/src/schemas/providers.ts | 5 +- apps/server/src/schemas/stripe.ts | 17 +- apps/server/src/schemas/user-character.ts | 9 +- apps/server/src/services/characters.ts | 52 +- apps/server/src/services/chats.ts | 105 + apps/server/src/services/email.ts | 43 + apps/server/src/services/flux.ts | 51 +- apps/server/src/services/providers.ts | 21 + apps/server/src/services/stripe.ts | 115 +- apps/server/src/services/tests/stripe.test.ts | 2 +- .../src/services/user-deletion/index.test.ts | 121 + .../src/services/user-deletion/index.ts | 77 + .../user-deletion/service-deletion.test.ts | 282 ++ .../src/services/user-deletion/types.ts | 89 + .../src/pages/delete-account.vue | 74 + packages/i18n/src/locales/en/server/auth.yaml | 10 +- packages/i18n/src/locales/en/settings.yaml | 20 +- .../i18n/src/locales/zh-Hans/server/auth.yaml | 12 +- .../i18n/src/locales/zh-Hans/settings.yaml | 22 +- .../account/account-settings-page.vue | 158 +- 33 files changed, 4489 insertions(+), 65 deletions(-) create mode 100644 apps/server/docs/ai-context/account-deletion.md create mode 100644 apps/server/docs/ai-context/verifications/account-deletion.md create mode 100644 apps/server/drizzle/0009_perpetual_lilandra.sql create mode 100644 apps/server/drizzle/meta/0009_snapshot.json create mode 100644 apps/server/src/services/user-deletion/index.test.ts create mode 100644 apps/server/src/services/user-deletion/index.ts create mode 100644 apps/server/src/services/user-deletion/service-deletion.test.ts create mode 100644 apps/server/src/services/user-deletion/types.ts create mode 100644 apps/ui-server-auth/src/pages/delete-account.vue diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md index 50c2fe271..7ea2668a4 100644 --- a/apps/server/docs/ai-context/README.md +++ b/apps/server/docs/ai-context/README.md @@ -31,8 +31,12 @@ - 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录 - `email-auth-resend.md` - Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项 +- `account-deletion.md` + - 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型 - `verifications/email-auth.md` - 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据 +- `verifications/account-deletion.md` + - 账号注销端到端验证:what's verified(schema/typecheck/units)和 what's pending(live DB + Resend + Stripe trace) ## 快速结论 diff --git a/apps/server/docs/ai-context/account-deletion.md b/apps/server/docs/ai-context/account-deletion.md new file mode 100644 index 000000000..5de506de8 --- /dev/null +++ b/apps/server/docs/ai-context/account-deletion.md @@ -0,0 +1,184 @@ +# Account Deletion + +User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance. + +## 决策摘要 + +| 决策点 | 选择 | 理由 | +|---|---|---| +| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 | +| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state | +| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" | +| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 | +| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) | +| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 | +| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) | +| Stripe 订阅 | **立即 cancel,不退款(v1)** | 简单、对内部记账影响最小。条款需注明。后续可改 | +| Flux 余额 | **清零(userFlux.deletedAt)** | 同上。后续可补退款逻辑 | + +## 流程 + +``` +用户在 settings/account 点 Delete + ↓ +POST /api/auth/delete-user (Bearer) ← better-auth + ↓ +sendDeleteAccountVerification → Resend ← 我们的 EmailService + ↓ 用户收邮件,点链接 +GET /api/auth/delete-user/callback?token=... ← better-auth 验 token + ↓ token 有效 +beforeDelete(user) ← UserDeletionService.softDeleteAll(userId) + ├─ stripe (priority 10): stripeService.deleteAllForUser + │ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt + ├─ flux (priority 20): fluxService.deleteAllForUser + │ → userFlux 打 deletedAt + redis cache 失效 + ├─ providers (priority 30): providerService.deleteAllForUser + │ → userProviderConfigs 打 deletedAt + ├─ characters (priority 30): characterService.deleteAllForUser + │ → character / likes / bookmarks 打 deletedAt + └─ chats (priority 30): chatService.deleteAllForUser + → chats / messages 打 deletedAt + ↓ +internalAdapter.deleteUser(userId) ← user 行真删 + ↓ Postgres FK cascade +session/account/oauth_client/oauth_*_token/oauth_consent ← 真删 + ↓ +重定向到 callbackURL +``` + +## 架构:service own 自己的删除语义 + +每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。 + +依赖图: + +``` +auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...] + │ + └─ 内部仅持有 { name, priority, softDelete } 列表, + softDelete 是对 service.deleteAllForUser 的 thin wrapper +``` + +auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。 + +```ts +// apps/server/src/services/user-deletion/types.ts +export interface UserDeletionHandler { + name: string + /** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */ + priority: number + softDelete: (ctx: UserDeletionContext) => Promise +} + +export interface UserDeletionService { + register: (handler: UserDeletionHandler) => void + softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise +} +``` + +装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotent:Stripe sub 已 cancel 的再 cancel 是 no-op;deletedAt 已设置的再 update 是 no-op)。 + +## 加新业务模块的步骤 + +1. 在该 service 加 `async deleteAllForUser(userId: string)` 方法 +2. 在 `app.ts` 的 `userDeletionService` build 里加一行 `service.register({...})` +3. 完成 + +不需要:写新文件、改 service 接口、改 auth.ts、改 types.ts。 + +## 各业务 service 的 deleteAllForUser + +| Service | priority | 内容 | 依赖 | +|---|---|---|---| +| **stripeService** | 10 | (1) 查 stripeSubscription where userId=? and status=active;(2) Stripe API `subscriptions.cancel(id, { prorate: false })`;(3) 4 张 `stripe_*` 表 update deletedAt=now() | DB, Stripe SDK (optional) | +| **fluxService** | 20 | (1) `update userFlux set deletedAt=now() where userId=?`;(2) `redis del flux:balance:{userId}`;(3) **不动** flux_transaction(账本审计) | DB, Redis | +| **providerService** | 30 | `update userProviderConfigs set deletedAt=now() where ownerId=?` | DB | +| **characterService** | 30 | (1) `character set deletedAt=now() where ownerId=? or creatorId=?`;(2) `characterLikes/Bookmarks set deletedAt=now() where userId=?` | DB | +| **chatService** | 30 | 按 `chat.type` 分支:① `private`/`bot` 整 chat soft-delete + 该 user 发的 message soft-delete;② `group`/`channel` 只硬删该 user 的 `chat_members` 行,**user 发的 message 保留**给其他 member 维持对话上下文(sender 通过"user 行 hard-delete + senderId bare text 无 FK"自然匿名化,UI 拿 senderId lookup 不到 user 时渲染为 "Deleted User") | DB | +| llm_request_log | 不参与 | 独立 retention job 处理 | — | + +## 业务查询的软删过滤 + +**所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描: + +- `apps/server/src/services/flux.ts` — getBalance / readBalance +- `apps/server/src/services/characters.ts` — listCharacters +- `apps/server/src/services/providers.ts` — listProviderConfigs +- `apps/server/src/services/chats.ts` — listChats / listMessages +- `apps/server/src/services/billing/billing-service.ts` — invoice / sub 查询 + +写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。 + +## Failure 模型 + +| 阶段失败 | 行为 | 后果 | +|---|---|---| +| sendDeleteAccountVerification | better-auth 抛 500 | 用户重试 | +| token 验失败/过期 | better-auth 返 404 | 用户重新发起 | +| Stripe handler 抛错 | 整个 beforeDelete 中止 → user 不删 | DB 状态保持原样,Stripe sub 状态可能已 cancel(罕见),下次重试 idempotent | +| Flux/其他 handler 抛错 | 同上中止 → user 不删 | 已经 cancel 的 Stripe sub 不会回滚(Stripe API 不支持 un-cancel),用户得重新订阅。**记录到 deletion_failure_log**(telemetry / sentry alert) | +| user 真删后 afterDelete 抛错 | user 已删,session 已 revoke,已无法回滚 | 仅 log,不影响用户体验 | + +**没有补偿事务**。Multi-step soft-delete 失败的处置策略是:失败即中止,依赖 idempotency 让重试干净。 + +## Idempotency + +- better-auth 的 verification token 一次性消费(`deleteVerificationByIdentifier`),点链接两次第二次会 404 +- handler 全部用 `update where deletedAt is null` 守卫,重跑无副作用 +- Stripe `subscriptions.cancel` 对已 cancel 的 sub 返回 200(idempotent by spec) + +## 群聊匿名化("Deleted User") + +群聊场景下 `messages.senderId` 故意是 **bare `text` 列没有 FK**,所以: + +- better-auth hard-delete `user` 行后,`messages.senderId='abc123'` 字符串还在,但 `select * from "user" where id='abc123'` 空集 +- name / email / avatar 全部跟 user 行一起没了 +- senderId 还能 group by(同一 user 发的 message 仍可识别为同一来源),但**反查不到任何 PII** +- UI 路径:渲染 message sender 时 user lookup miss → 显示 "Deleted User" / "[已注销]" + +**chatService.deleteAllForUser 不需要主动改 senderId**,schema "bare text + 无 FK + auth user 行 hard-delete" 这三件事联合产出匿名化效果。 + +## 第三方 OAuth provider 端 + +better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 google/github 登录方式的关联),oauth_* 表通过 FK cascade 删干净。**第三方 OAuth provider 那边的 grant 不主动撤销** —— 跟 Stripe / Slack / Discord 等业界默认一致。User 真要彻底清,应该去 OAuth provider 自己的 dashboard(如 google.com/security)撤。 + +如果未来出现严格 GDPR 需求,可以加 best-effort 调 Google `/o/oauth2/revoke?token=...` —— 但需要保留 refresh token,且 endpoint 本身就是 best-effort。 + +## 不做项 (v1) + +- ❌ 软删 → hard delete reaper job(业务表保留无限期,等首次清理需求驱动;llm_request_log 已有独立 retention) +- ❌ 误删恢复(用户认知中删除即终态;UI 必须文案警示) +- ❌ Stripe / Flux 退款(条款里写明,后续按需补) +- ❌ 删除事件外发 Webhook / Slack 通知(用 telemetry 替代) +- ❌ Admin 手动触发 delete(后续 admin panel 任务) +- ❌ 主动撤销第三方 OAuth provider 端的 grant(业界默认不做,user 自助撤) + +## 相关代码索引 + +- 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts` +- Auth schema (不改): `apps/server/src/schemas/accounts.ts` +- Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`) +- Email service: `apps/server/src/services/email.ts` (extend interface + Resend impl) +- Deletion scheduler: `apps/server/src/services/user-deletion/` (registry only, no domain logic) +- 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts` +- UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO) +- UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue` +- i18n: `packages/i18n/src/locales/{en,zh}/settings/account.yaml` + +## Verification + +实测路径见 `docs/ai/context/verifications/account-deletion.md`(待补)。 + +最小路径: + +1. 注册 user A +2. 创建一个 character,给 5 flux,订阅 active sub(mock Stripe) +3. UI 点 Delete → 收邮件 → 点链接 +4. 验证: + - `select * from "user" where email='A'` 空 + - `select * from session where user_id='A'` 空 + - `select * from user_flux where user_id='A'` deleted_at 非空 + - `select * from character where owner_id='A'` deleted_at 非空 + - `select * from stripe_subscription where user_id='A'` deleted_at 非空,Stripe API 端 sub status=canceled + - `select * from flux_transaction where user_id='A'` 仍存在(账本审计) +5. 重新用 email A 注册成功(unique 约束已释放) diff --git a/apps/server/docs/ai-context/verifications/account-deletion.md b/apps/server/docs/ai-context/verifications/account-deletion.md new file mode 100644 index 000000000..3c55394eb --- /dev/null +++ b/apps/server/docs/ai-context/verifications/account-deletion.md @@ -0,0 +1,131 @@ +# Verification: account deletion + +Status: **end-to-end verified (2026-04-28)** — UI → email → click → server +soft-delete pipeline → success page all confirmed in a live run. DB-row +inspection (`select deleted_at` on each business table) and same-email +re-registration smoke-test are recommended but not yet captured in this +record. +Last attempted: 2026-04-28 +Owner: rbxin2003@gmail.com + +## Live trace (2026-04-28) + +Server log captured during a live deletion of `userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk`: + +``` +<-- POST /api/auth/delete-user +--> POST /api/auth/delete-user 200 4s +<-- GET /api/auth/delete-user/callback?token=sb7614...&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fdelete-account +[user-deletion] starting user deletion { userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk reason=user-requested handlerCount=5 } +[user-deletion] handler completed { handler=stripe userId=... durationMs=1297 } +[user-deletion] Flux balance soft-deleted... { userId=... clearedFlux=500 } +[user-deletion] handler completed { handler=flux userId=... durationMs=526 } +[user-deletion] Provider configs soft-deleted { userId=... count=0 } +[user-deletion] handler completed { handler=providers userId=... durationMs=260 } +[user-deletion] Characters / likes / bookmarks soft-deleted { userId=... characters=0 likes=0 bookmarks=0 } +[user-deletion] handler completed { handler=characters userId=... durationMs=787 } +[user-deletion] Chats / messages soft-deleted { userId=... chats=0 messages=0 } +[user-deletion] handler completed { handler=chats userId=... durationMs=270 } +[user-deletion] user deletion handlers completed { userId=... reason=user-requested } +--> GET /api/auth/delete-user/callback?... 302 8s +<-- GET /auth/delete-account +--> GET /auth/delete-account 200 1ms +``` + +What this proves: +- 5 handlers run in registration order, ascending priority (stripe → flux → providers → characters → chats). +- Total handler time ~3.1s (mostly Stripe: 1.3s for the network round-trip). +- Verification token consumed exactly once; the callback redirected (302) to the success page. +- `clearedFlux=500` confirms the Flux handler picked up the actual balance. +- `count=0` for providers / characters / chats reflects the test user not having those records — empty soft-delete is a valid no-op. + +## Known gotcha — UI dist staleness + +`apps/server/public/ui-server-auth/` is a build artifact (Vite `outDir`). New +pages added under `apps/ui-server-auth/src/pages/` only show up after +running `pnpm -F @proj-airi/ui-server-auth build`. Symptom of forgetting: +the success page returns `200` with the SPA HTML but renders blank because +vue-router never registered the route. Re-build → fixes. + +## What is verified + +| Layer | Evidence | Date | +|---|---|---| +| Schema migration generated | `apps/server/drizzle/0009_perpetual_lilandra.sql`: 11 `DROP CONSTRAINT` + 7 `ADD COLUMN deleted_at` (no destructive ALTER beyond FK drop) | 2026-04-28 | +| Server typecheck | `pnpm -F @proj-airi/server typecheck` exits clean | 2026-04-28 | +| Monorepo typecheck | `pnpm typecheck` exits clean across all packages | 2026-04-28 | +| Lint | `pnpm lint` reports 0 errors in deletion-service / handler / UI files | 2026-04-28 | +| Deletion service unit tests | `pnpm exec vitest run apps/server/src/services/user-deletion` → `2 files / 14 tests pass` (registry priority order, abort-on-error, serial execution, idempotency, per-service `deleteAllForUser` correctness) | 2026-04-28 | +| Architecture refactor | Domain knowledge moved out of `*-deletion-handler.ts` files into each business service's own `deleteAllForUser` method. Registry retained as a thin scheduler. `auth → userDeletionService → 5 business services` (auth and services no longer depend on each other). | 2026-04-28 | +| Server full test suite | `pnpm -F @proj-airi/server exec vitest run` → 244/245 pass; the single failure (`origin.test.ts`) is pre-existing on `main` and unrelated | 2026-04-28 | +| UI typecheck | `pnpm -F @proj-airi/stage-pages typecheck` and `pnpm -F @proj-airi/ui-server-auth typecheck` both clean | 2026-04-28 | +| Live server-side trace | See "Live trace" section above — full pipeline ran against real DB + Stripe sandbox + Resend | 2026-04-28 | + +## What is **not** verified yet (action items) + +### Path A — Migration applies cleanly to live DB +**Command (run by user):** +```sh +pnpm -F @proj-airi/server db:push +``` +**Expected:** Drizzle reports `Changes applied` for 11 FK drops + 7 column additions on the local Postgres pointed to by `DATABASE_URL`. +**Risk:** if any business table currently has rows whose `userId` references a now-missing user (orphans from older bugs), DROP CONSTRAINT will succeed (unlike ADD CONSTRAINT). No data loss expected. + +### Path B — Server boots with deletion service wired +**Command:** +```sh +pnpm -F @proj-airi/server dev +``` +**Expected log lines:** +- `injeca` resolves `services:userDeletion` without error +- `services:auth` resolves successfully (depends on userDeletionService) +- `Server started` log line appears +**Failure mode:** if the Stripe SDK construction throws at boot, the deletion service crashes the process. Mitigation: `STRIPE_SECRET_KEY` is optional — handler tolerates `null`. + +### Path C — End-to-end deletion flow (UI → email → DB) +**Setup:** +1. Server up (Path B), UI up (`pnpm -F @proj-airi/stage-web dev` with `VITE_SERVER_URL=http://localhost:3000`) +2. `RESEND_API_KEY` valid, use `rbxin2003+delete@outlook.com` (bare address suppressed — see Resend memory) +3. Pre-populate the user with non-trivial data so soft-delete has something to mark: + - Register user + - Have flux balance > 0 (initial grant covers this) + - Connect a provider via settings UI + - Create one character + - (Optional) Set up a Stripe test sub via the billing flow + +**Steps + assertions:** + +| # | Action | Expected | DB / API check | +|---|---|---|---| +| 1 | Settings → Account → Danger Zone → click "Delete account" | Inline confirm form appears | DOM-only | +| 2 | Type wrong email | Confirm button disabled | DOM-only | +| 3 | Type correct email + click confirm | Server log: `POST /api/auth/delete-user 200`. UI shows "Check {email} for the deletion link" | `select * from verification where identifier like 'delete-account-%'` returns one row | +| 4 | Open Resend inbox, click link in email | Browser navigates to `${API_SERVER_URL}/api/auth/delete-user/callback?token=...` then redirects to `/auth/delete-account` (success page) | server log: `[user-deletion] starting user deletion` → 5x `handler completed` → `user deletion handlers completed` → `internalAdapter.deleteUser` → `302` | +| 5 | Verify auth tables are gone (cascade) | `select * from "user" where email='...'` → empty | psql query | +| 6 | Verify business tables are soft-deleted (NOT cascade) | `select * from user_flux where user_id=$1` → row with `deleted_at IS NOT NULL` | psql query, $1 = old user.id | +| 7 | Same for stripe_customer, stripe_subscription, stripe_checkout_session, stripe_invoice | All `deleted_at IS NOT NULL` | psql query | +| 8 | Same for character (creator_id OR owner_id), user_provider_configs (owner_id), user_character_likes/bookmarks (user_id) | All matching rows have `deleted_at IS NOT NULL` | psql query | +| 9 | flux_transaction is **untouched** (audit) | `select count(*) from flux_transaction where user_id=$1` → unchanged from before deletion | psql query | +| 10 | Stripe API side: active sub canceled | Stripe dashboard or `GET /v1/subscriptions/$sub_id` → `status: "canceled"` | Stripe CLI or dashboard | +| 11 | Re-register with same email | Sign-up succeeds (unique constraint released by hard delete of `user` row) | server log: `POST /api/auth/sign-up/email 200`. New user gets a fresh user.id | +| 12 | Old soft-deleted business rows do **not** show up for the new user | `select * from user_flux where user_id=$1` (new id) is empty or has fresh row | psql query | + +### Path D — Failure-mode smoke +**Setup:** kill Postgres mid-deletion (or simulate by wrapping a handler to throw) +**Expected:** +- `user-deletion` log: `handler failed; aborting deletion pipeline` +- `user` row still present (better-auth never reaches `internalAdapter.deleteUser`) +- soft-deleted rows from earlier handlers REMAIN soft-deleted (no rollback) — by design +- User can retry the deletion flow; idempotent handlers re-mark already-stamped rows as no-ops + +## Known gaps + +1. **No retry UI:** if Path D triggers, the user sees the API JSON error from `/delete-user/callback` instead of a friendly page. Acceptable for v1 (rare path); future hook can redirect to `/auth/delete-account?error=...`. +2. **No admin-triggered deletion:** the `reason: 'admin'` enum exists but no caller. Wire up in admin panel later. +3. **No rate limiting on `/api/auth/delete-user`:** uses the global `/api/auth/*` IP rate limit (`AUTH_RATE_LIMIT_MAX`). May want a tighter per-user cap (e.g. 1 attempt per hour) if abuse surfaces. +4. **Translations:** EN-only for new i18n keys (`server.auth.deleteAccount.*`, `settings.pages.account.danger.deleteAccount.modal.*`, `.message.emailSent`, `.error.fallback`). Other locales fall back to EN until translated. +5. **`flux_transaction` retention:** ledger now contains `user_id` strings that point to deleted users. No retention policy yet — open question for finance/legal. + +## Re-verification cadence + +When the deletion code path is touched (handler logic, schema, better-auth config), re-run Path C from scratch and update "Last attempted" + the table above. diff --git a/apps/server/drizzle/0009_perpetual_lilandra.sql b/apps/server/drizzle/0009_perpetual_lilandra.sql new file mode 100644 index 000000000..af90041b3 --- /dev/null +++ b/apps/server/drizzle/0009_perpetual_lilandra.sql @@ -0,0 +1,29 @@ +ALTER TABLE "characters" DROP CONSTRAINT "characters_creator_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "characters" DROP CONSTRAINT "characters_owner_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "flux_transaction" DROP CONSTRAINT "flux_transaction_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_flux" DROP CONSTRAINT "user_flux_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_provider_configs" DROP CONSTRAINT "user_provider_configs_owner_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "stripe_checkout_session" DROP CONSTRAINT "stripe_checkout_session_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "stripe_customer" DROP CONSTRAINT "stripe_customer_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "stripe_invoice" DROP CONSTRAINT "stripe_invoice_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "stripe_subscription" DROP CONSTRAINT "stripe_subscription_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "user_character_bookmarks_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_character_likes" DROP CONSTRAINT "user_character_likes_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_flux" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "stripe_checkout_session" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "stripe_customer" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "stripe_invoice" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "stripe_subscription" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "user_character_bookmarks" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "user_character_likes" ADD COLUMN "deleted_at" timestamp; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0009_snapshot.json b/apps/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..fa1f6d95a --- /dev/null +++ b/apps/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2755 @@ +{ + "id": "86b6aa8d-03e2-40f3-8331-582d0bd3c399", + "prevId": "e77aa17f-3071-42f9-948b-8b9ac9d0b457", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index b49c6b333..699acbe0d 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1775032828818, "tag": "0008_gray_xavin", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1777370103031, + "tag": "0009_perpetual_lilandra", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 9fb42ba56..936e91034 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -65,6 +65,7 @@ function createTestDeps() { API_SERVER_URL: 'http://localhost:3000', } as any, otel: null, + userDeletionService: {} as any, } return { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 822b7d640..5b3d15483 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -15,10 +15,13 @@ import type { FluxService } from './services/flux' import type { FluxTransactionService } from './services/flux-transaction' import type { ProviderService } from './services/providers' import type { StripeService } from './services/stripe' +import type { UserDeletionService } from './services/user-deletion' import type { HonoEnv } from './types/hono' import process from 'node:process' +import Stripe from 'stripe' + import { initLogger, LoggerFormat, LoggerLevel, setGlobalHookPostLog, useLogger } from '@guiiai/logg' import { serve } from '@hono/node-server' import { createNodeWebSocket } from '@hono/node-ws' @@ -57,6 +60,7 @@ import { createFluxTransactionService } from './services/flux-transaction' import { createProviderService } from './services/providers' import { createRequestLogService } from './services/request-log' import { createStripeService } from './services/stripe' +import { createUserDeletionService } from './services/user-deletion' import { ApiError, createInternalError, createUnauthorizedError } from './utils/error' import { getTrustedOrigin } from './utils/origin' @@ -76,6 +80,7 @@ interface AppDeps { redis: Redis env: Env otel: OtelInstance | null + userDeletionService: UserDeletionService } export async function buildApp(deps: AppDeps) { @@ -325,24 +330,6 @@ export async function createApp() { }), }) - const auth = injeca.provide('services:auth', { - dependsOn: { db, env: parsedEnv, otel, email: emailService }, - build: async ({ dependsOn }) => { - // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied - await seedTrustedClients(dependsOn.db, dependsOn.env) - const trustedClients = getTrustedClientSeedSummaries(dependsOn.env) - logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration') - for (const client of trustedClients) { - logger.withFields({ - clientId: client.clientId, - clientName: client.name, - redirectUris: client.redirectUris.join(', '), - }).log('OIDC trusted client ready') - } - return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth) - }, - }) - const characterService = injeca.provide('services:characters', { dependsOn: { db, otel }, build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement), @@ -359,8 +346,14 @@ export async function createApp() { }) const stripeService = injeca.provide('services:stripe', { - dependsOn: { db }, - build: ({ dependsOn }) => createStripeService(dependsOn.db), + dependsOn: { db, env: parsedEnv }, + build: ({ dependsOn }) => { + // Stripe SDK is optional — when STRIPE_SECRET_KEY is unset (dev/CI) + // billing routes degrade gracefully and the user-deletion pipeline + // skips the API cancel call. + const stripe = dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null + return createStripeService(dependsOn.db, stripe) + }, }) const fluxTransactionService = injeca.provide('services:fluxTransaction', { @@ -373,6 +366,47 @@ export async function createApp() { build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV), }) + // NOTICE: + // The deletion service is a thin scheduler that delegates to each business + // service's own `deleteAllForUser` method. Adding a new business module: + // 1. give it a `deleteAllForUser(userId)` method + // 2. add one `service.register(...)` line below + // Domain knowledge stays inside each service instead of being copied into + // a parallel handler file. See `apps/server/docs/ai-context/account-deletion.md`. + const userDeletionService = injeca.provide('services:userDeletion', { + dependsOn: { stripeService, fluxService, providerService, characterService, chatService }, + build: ({ dependsOn }) => { + const service = createUserDeletionService() + // priority: 10 = external side-effects (Stripe API cancel — unrollable), + // 20 = financial / cache state (Flux balance + Redis), + // 30 = pure DB soft-delete (no external touch). + service.register({ name: 'stripe', priority: 10, softDelete: ({ userId }) => dependsOn.stripeService.deleteAllForUser(userId) }) + service.register({ name: 'flux', priority: 20, softDelete: ({ userId }) => dependsOn.fluxService.deleteAllForUser(userId) }) + service.register({ name: 'providers', priority: 30, softDelete: ({ userId }) => dependsOn.providerService.deleteAllForUser(userId) }) + service.register({ name: 'characters', priority: 30, softDelete: ({ userId }) => dependsOn.characterService.deleteAllForUser(userId) }) + service.register({ name: 'chats', priority: 30, softDelete: ({ userId }) => dependsOn.chatService.deleteAllForUser(userId) }) + return service + }, + }) + + const auth = injeca.provide('services:auth', { + dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService }, + build: async ({ dependsOn }) => { + // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied + await seedTrustedClients(dependsOn.db, dependsOn.env) + const trustedClients = getTrustedClientSeedSummaries(dependsOn.env) + logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration') + for (const client of trustedClients) { + logger.withFields({ + clientId: client.clientId, + clientName: client.name, + redirectUris: client.redirectUris.join(', '), + }).log('OIDC trusted client ready') + } + return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService) + }, + }) + const requestLogService = injeca.provide('services:requestLog', { dependsOn: { db }, build: ({ dependsOn }) => createRequestLogService(dependsOn.db), @@ -419,6 +453,7 @@ export async function createApp() { redis, env: parsedEnv, otel, + userDeletionService, }) const { app, injectWebSocket } = await buildApp({ auth: resolved.auth, @@ -436,6 +471,7 @@ export async function createApp() { redis: resolved.redis, env: resolved.env, otel: resolved.otel, + userDeletionService: resolved.userDeletionService, }) logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 6478377fa..fe2d00d8f 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,4 +1,5 @@ import type { EmailService } from '../services/email' +import type { UserDeletionService } from '../services/user-deletion' import type { Database } from './db' import type { Env } from './env' import type { AuthMetrics } from './otel' @@ -333,7 +334,32 @@ function requireEmailService(email: EmailService | undefined): EmailService { return email } -export function createAuth(db: Database, env: Env, email?: EmailService, metrics?: AuthMetrics | null) { +/** + * NOTICE: + * `userDeletionService` is optional for the same reason `email` is — the + * `auth:generate` schema introspection path constructs `createAuth` without + * a real DI graph and never exercises `user.deleteUser`. The runtime path + * always supplies it from `app.ts`, and the `beforeDelete` callback throws + * if it's missing so silent no-ops are impossible. + */ +function requireUserDeletionService(service: UserDeletionService | undefined): UserDeletionService { + if (!service) { + throw new ApiError( + 503, + 'user-deletion/service_not_configured', + 'User deletion service not available in this server context.', + ) + } + return service +} + +export function createAuth( + db: Database, + env: Env, + email?: EmailService, + metrics?: AuthMetrics | null, + userDeletionService?: UserDeletionService, +) { return betterAuth({ secret: env.BETTER_AUTH_SECRET, @@ -452,6 +478,33 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics }) }, }, + // NOTICE: + // Two-step deletion: POST /api/auth/delete-user with an authenticated + // session triggers `sendDeleteAccountVerification`; clicking the link + // hits GET /api/auth/delete-user/callback?token=..., which validates + // and calls `beforeDelete` BEFORE `internalAdapter.deleteUser`. Throw + // from `beforeDelete` to abort: the user row stays put, the + // verification token has already been consumed (single-use) so the + // user must re-initiate. Soft-delete handlers must be idempotent + // because retrying a partial deletion re-runs already-completed + // handlers as no-ops. + // Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-380 + // Design: apps/server/docs/ai-context/account-deletion.md + deleteUser: { + enabled: true, + async sendDeleteAccountVerification({ user, url }) { + await requireEmailService(email).sendDeleteAccountVerification({ + to: user.email, + url, + }) + }, + async beforeDelete(user) { + await requireUserDeletionService(userDeletionService).softDeleteAll({ + userId: user.id, + reason: 'user-requested', + }) + }, + }, }, session: { diff --git a/apps/server/src/routes/stripe/route.test.ts b/apps/server/src/routes/stripe/route.test.ts index 99f3b80f3..bb344c467 100644 --- a/apps/server/src/routes/stripe/route.test.ts +++ b/apps/server/src/routes/stripe/route.test.ts @@ -99,6 +99,7 @@ function createCheckoutSession(overrides: Partial = {}): expiresAt: null, createdAt: new Date(), updatedAt: new Date(), + deletedAt: null, ...overrides, } } @@ -123,6 +124,7 @@ function createInvoice(overrides: Partial = {}): StripeInvoice { metadata: null, createdAt: new Date(), updatedAt: new Date(), + deletedAt: null, ...overrides, } } diff --git a/apps/server/src/schemas/characters.ts b/apps/server/src/schemas/characters.ts index a7482ea3a..bcf9f6682 100644 --- a/apps/server/src/schemas/characters.ts +++ b/apps/server/src/schemas/characters.ts @@ -19,8 +19,12 @@ export const character = pgTable( // TODO: json patch? - creatorId: text('creator_id').notNull().references(() => user.id, { onDelete: 'cascade' }), - ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + // NOTICE: bare creatorId / ownerId is intentional — no FK to user.id. + // better-auth hard-deletes the user row; a cascade would wipe these + // soft-delete archive rows. + // See `apps/server/docs/ai-context/account-deletion.md`. + creatorId: text('creator_id').notNull(), + ownerId: text('owner_id').notNull(), characterId: text('character_id').notNull(), avatarUrl: text('avatar_url'), creatorRole: text('creator_role'), diff --git a/apps/server/src/schemas/flux-transaction.ts b/apps/server/src/schemas/flux-transaction.ts index 4bfca849e..c7f388f93 100644 --- a/apps/server/src/schemas/flux-transaction.ts +++ b/apps/server/src/schemas/flux-transaction.ts @@ -2,11 +2,14 @@ import { sql } from 'drizzle-orm' import { bigint, index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' -import { user } from './accounts' +// NOTICE: ledger is permanent — bare userId (no FK) and no `deletedAt` column, +// both intentional. Entries must outlive the user row, and better-auth's +// hard-delete of user.id must not cascade-wipe the ledger. +// See `apps/server/docs/ai-context/account-deletion.md`. export const fluxTransaction = pgTable('flux_transaction', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), type: text('type').notNull(), // 'credit' | 'debit' | 'initial' amount: bigint('amount', { mode: 'number' }).notNull(), // always positive balanceBefore: bigint('balance_before', { mode: 'number' }).notNull(), diff --git a/apps/server/src/schemas/flux.ts b/apps/server/src/schemas/flux.ts index d4b8fff5b..1f233580b 100644 --- a/apps/server/src/schemas/flux.ts +++ b/apps/server/src/schemas/flux.ts @@ -1,10 +1,12 @@ import { bigint, pgTable, text, timestamp } from 'drizzle-orm/pg-core' -import { user } from './accounts' - +// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes +// the user row; a cascade would wipe these soft-delete archive rows. +// See `apps/server/docs/ai-context/account-deletion.md`. export const userFlux = pgTable('user_flux', { - userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').primaryKey(), flux: bigint('flux', { mode: 'number' }).notNull().default(0), stripeCustomerId: text('stripe_customer_id'), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }) diff --git a/apps/server/src/schemas/providers.ts b/apps/server/src/schemas/providers.ts index c8d73df7f..5c045100d 100644 --- a/apps/server/src/schemas/providers.ts +++ b/apps/server/src/schemas/providers.ts @@ -6,11 +6,14 @@ import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' import { user } from './accounts' +// NOTICE: bare ownerId is intentional — no FK to user.id. better-auth hard-deletes +// the user row; a cascade would wipe these soft-delete archive rows. +// See `apps/server/docs/ai-context/account-deletion.md`. export const userProviderConfigs = pgTable( 'user_provider_configs', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + ownerId: text('owner_id').notNull(), definitionId: text('definition_id').notNull(), name: text('name').notNull(), config: jsonb('config').notNull().default({}), diff --git a/apps/server/src/schemas/stripe.ts b/apps/server/src/schemas/stripe.ts index 2b7193703..2612ce100 100644 --- a/apps/server/src/schemas/stripe.ts +++ b/apps/server/src/schemas/stripe.ts @@ -6,17 +6,23 @@ import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' import { user } from './accounts' +// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes +// the user row; a cascade would wipe these soft-delete archive rows kept for +// audit / billing review. +// See `apps/server/docs/ai-context/account-deletion.md`. + /** * Stripe customers linked to our users. */ export const stripeCustomer = pgTable('stripe_customer', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), stripeCustomerId: text('stripe_customer_id').notNull().unique(), email: text('email'), name: text('name'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }) /** @@ -24,7 +30,7 @@ export const stripeCustomer = pgTable('stripe_customer', { */ export const stripeCheckoutSession = pgTable('stripe_checkout_session', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), stripeSessionId: text('stripe_session_id').notNull().unique(), stripeCustomerId: text('stripe_customer_id'), mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup' @@ -41,6 +47,7 @@ export const stripeCheckoutSession = pgTable('stripe_checkout_session', { expiresAt: timestamp('expires_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }) /** @@ -48,7 +55,7 @@ export const stripeCheckoutSession = pgTable('stripe_checkout_session', { */ export const stripeSubscription = pgTable('stripe_subscription', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(), stripeCustomerId: text('stripe_customer_id').notNull(), stripePriceId: text('stripe_price_id'), @@ -61,6 +68,7 @@ export const stripeSubscription = pgTable('stripe_subscription', { metadata: text('metadata'), // JSON stringified createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }) /** @@ -68,7 +76,7 @@ export const stripeSubscription = pgTable('stripe_subscription', { */ export const stripeInvoice = pgTable('stripe_invoice', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), stripeInvoiceId: text('stripe_invoice_id').notNull().unique(), stripeCustomerId: text('stripe_customer_id'), stripeSubscriptionId: text('stripe_subscription_id'), @@ -85,6 +93,7 @@ export const stripeInvoice = pgTable('stripe_invoice', { metadata: text('metadata'), // JSON stringified createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }) // ---------- Relations ---------- diff --git a/apps/server/src/schemas/user-character.ts b/apps/server/src/schemas/user-character.ts index 3c5a7d62c..e7b882943 100644 --- a/apps/server/src/schemas/user-character.ts +++ b/apps/server/src/schemas/user-character.ts @@ -6,12 +6,16 @@ import { relations } from 'drizzle-orm/relations' import { user } from './accounts' import { character } from './characters' +// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes +// the user row; a cascade would wipe these soft-delete archive rows. +// See `apps/server/docs/ai-context/account-deletion.md`. export const characterLikes = pgTable( 'user_character_likes', { - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, table => [ primaryKey({ columns: [table.userId, table.characterId] }), @@ -24,9 +28,10 @@ export type NewCharacterLike = InferInsertModel export const characterBookmarks = pgTable( 'user_character_bookmarks', { - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, table => [ primaryKey({ columns: [table.userId, table.characterId] }), diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/characters.ts index 8aace4b1b..bdc7603a3 100644 --- a/apps/server/src/services/characters.ts +++ b/apps/server/src/services/characters.ts @@ -2,7 +2,7 @@ import type { Database } from '../libs/db' import type { EngagementMetrics } from '../libs/otel' import { useLogger } from '@guiiai/logg' -import { and, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull, or, sql } from 'drizzle-orm' import * as schema from '../schemas/characters' import * as userCharacterSchema from '../schemas/user-character' @@ -219,6 +219,56 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics } return result }, + + /** + * Soft-delete every character owned or created by the user, plus their + * likes and bookmarks. Called from the user-deletion pipeline. + * + * Marks `creatorId === userId` rows too — fork attribution is tied to the + * creator's identity, so removing the creator soft-archives the lineage + * even if the current owner is someone else. + * + * Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows. + */ + async deleteAllForUser(userId: string) { + const now = new Date() + + const charRows = await db.update(schema.character) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + or( + eq(schema.character.ownerId, userId), + eq(schema.character.creatorId, userId), + ), + isNull(schema.character.deletedAt), + )) + .returning({ id: schema.character.id }) + + const likeRows = await db.update(userCharacterSchema.characterLikes) + .set({ deletedAt: now }) + .where(and( + eq(userCharacterSchema.characterLikes.userId, userId), + isNull(userCharacterSchema.characterLikes.deletedAt), + )) + .returning({ characterId: userCharacterSchema.characterLikes.characterId }) + + const bookmarkRows = await db.update(userCharacterSchema.characterBookmarks) + .set({ deletedAt: now }) + .where(and( + eq(userCharacterSchema.characterBookmarks.userId, userId), + isNull(userCharacterSchema.characterBookmarks.deletedAt), + )) + .returning({ characterId: userCharacterSchema.characterBookmarks.characterId }) + + logger + .withFields({ + userId, + characters: charRows.length, + likes: likeRows.length, + bookmarks: bookmarkRows.length, + }) + .log('Characters / likes / bookmarks soft-deleted for user') + }, } } diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/chats.ts index 5c3f5c3af..c53b71fef 100644 --- a/apps/server/src/services/chats.ts +++ b/apps/server/src/services/chats.ts @@ -305,6 +305,111 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq } }, + /** + * Soft-delete the user's footprint in chats. Per-chat strategy depends + * on `chat.type`: + * + * - `private` / `bot` (1-on-1, user IS the chat): soft-delete the chat + * row + the user's messages. Nothing else can read those messages + * (chat is gone), so soft-deleting them is just keeping audit consistent. + * + * - `group` / `channel` (shared): drop only this user's `chat_members` + * row; the chat + other members survive. The user's messages are + * **kept intact** — deleting them would corrupt the conversation + * context for remaining members ("B replied to nothing"). Sender + * anonymization is automatic: `messages.senderId` is bare text with + * no FK, so after better-auth hard-deletes the user row, the senderId + * string still groups the user's messages together but cannot be + * joined to any PII (name / email are gone with the user row). The + * UI is expected to render `senderId` whose user lookup misses as + * "Deleted User". + * + * `chat_members` rows for shared chats are **hard-deleted** because the + * table was designed without a `deletedAt` column; auditing who was in + * which chat is preserved through `messages.senderId` for the messages + * the user actually authored. + * + * Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows on + * retry; re-deleting an already-removed `chat_members` row is a no-op. + */ + async deleteAllForUser(userId: string) { + const now = new Date() + + // Join chat_members → chats so we can branch by chat.type without a + // second round-trip per row. + const memberChats = await db + .select({ chatId: schema.chatMembers.chatId, chatType: schema.chats.type }) + .from(schema.chatMembers) + .innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id)) + .where(eq(schema.chatMembers.userId, userId)) + + const soloChatIds = memberChats + .filter(r => r.chatType === 'private' || r.chatType === 'bot') + .map(r => r.chatId) + const sharedChatIds = memberChats + .filter(r => r.chatType === 'group' || r.chatType === 'channel') + .map(r => r.chatId) + + let soloChatCount = 0 + let droppedMemberships = 0 + let soloMessageCount = 0 + let preservedSharedMessages = 0 + + if (soloChatIds.length > 0) { + const updatedChats = await db.update(schema.chats) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + inArray(schema.chats.id, soloChatIds), + isNull(schema.chats.deletedAt), + )) + .returning({ id: schema.chats.id }) + soloChatCount = updatedChats.length + + // Soft-delete user-authored messages in solo chats only. The chat + // itself is gone, so this is purely audit/consistency hygiene. + const updatedMessages = await db.update(schema.messages) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + inArray(schema.messages.chatId, soloChatIds), + eq(schema.messages.senderId, userId), + isNull(schema.messages.deletedAt), + )) + .returning({ id: schema.messages.id }) + soloMessageCount = updatedMessages.length + } + + if (sharedChatIds.length > 0) { + const dropped = await db.delete(schema.chatMembers) + .where(and( + inArray(schema.chatMembers.chatId, sharedChatIds), + eq(schema.chatMembers.userId, userId), + )) + .returning({ id: schema.chatMembers.id }) + droppedMemberships = dropped.length + + // Count (do not mutate) the user's messages in shared chats to make + // the preservation visible in logs. These rows stay live so other + // members keep their conversation context; sender anonymizes itself + // once better-auth hard-deletes the user row. + const kept = await db.select({ id: schema.messages.id }) + .from(schema.messages) + .where(and( + inArray(schema.messages.chatId, sharedChatIds), + eq(schema.messages.senderId, userId), + isNull(schema.messages.deletedAt), + )) + preservedSharedMessages = kept.length + } + + logger.withFields({ + userId, + soloChats: soloChatCount, + sharedChatMembershipsDropped: droppedMemberships, + soloMessages: soloMessageCount, + preservedSharedMessages, + }).log('Chats footprint processed for user (solo soft-deleted, shared anonymized)') + }, + async pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) { return db.transaction(async (tx) => { await verifyMembership(tx, chatId, userId) diff --git a/apps/server/src/services/email.ts b/apps/server/src/services/email.ts index 38a94c994..2fd6988e2 100644 --- a/apps/server/src/services/email.ts +++ b/apps/server/src/services/email.ts @@ -47,6 +47,17 @@ export interface EmailService { sendPasswordReset: (params: { to: string, url: string }) => Promise sendMagicLink: (params: { to: string, url: string }) => Promise sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise + /** + * Send the irreversible-action confirmation for `user.deleteUser` flow. + * + * Wired into better-auth's `user.deleteUser.sendDeleteAccountVerification`. + * The link expires per `deleteTokenExpiresIn` (default 24h) and is + * single-use; clicking it triggers `beforeDelete` → soft-delete handlers → + * hard-delete user. + * + * Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300. + */ + sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise } interface EmailConfig { @@ -166,6 +177,14 @@ export function createEmailService(config: EmailConfig, logger: Logger = useLogg text: renderChangeEmailText(url, newEmail), }) }, + async sendDeleteAccountVerification({ to, url }) { + await send({ + to, + subject: 'Confirm account deletion for Project AIRI', + html: renderDeleteAccountHtml(url), + text: renderDeleteAccountText(url), + }) + }, } } @@ -274,3 +293,27 @@ function renderChangeEmailText(url: string, newEmail: string): string { footer: 'If you did not request this change, contact support immediately.', }) } + +// NOTICE: +// Wording is intentionally short and direct. Account deletion hard-deletes +// the auth identity (cascade) and soft-archives business records; the user +// cannot recover the account through the UI. +// See `apps/server/docs/ai-context/account-deletion.md`. +function renderDeleteAccountHtml(url: string): string { + return renderActionEmailHtml({ + heading: 'Confirm account deletion', + body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.', + ctaLabel: 'Delete my account', + url, + footer: 'Did not request this? Ignore this email and rotate your password.', + }) +} + +function renderDeleteAccountText(url: string): string { + return renderActionEmailText({ + heading: 'Confirm account deletion', + body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.', + url, + footer: 'Did not request this? Ignore this email and rotate your password.', + }) +} diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts index b9674ee4a..f535a16c0 100644 --- a/apps/server/src/services/flux.ts +++ b/apps/server/src/services/flux.ts @@ -4,7 +4,7 @@ import type { Database } from '../libs/db' import type { ConfigKVService } from './config-kv' import { useLogger } from '@guiiai/logg' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { userFluxRedisKey } from '../utils/redis-keys' @@ -13,6 +13,11 @@ import * as fluxTxSchema from '../schemas/flux-transaction' const logger = useLogger('flux-service') +// NOTICE: +// All read paths here treat soft-deleted rows (`deletedAt IS NOT NULL`) as +// invisible. After account deletion the auth tables hard-delete the user +// so this filter is mostly defense-in-depth against routes that bypass +// `sessionMiddleware`. See `apps/server/docs/ai-context/account-deletion.md`. export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) { return { async getFlux(userId: string) { @@ -24,7 +29,10 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV // 2. Cache miss — load from DB let record = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, userId), + where: and( + eq(schema.userFlux.userId, userId), + isNull(schema.userFlux.deletedAt), + ), }) if (!record) { @@ -52,7 +60,10 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV // Re-read to handle race condition (another request may have initialized first) record = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, userId), + where: and( + eq(schema.userFlux.userId, userId), + isNull(schema.userFlux.deletedAt), + ), }) if (!record) { @@ -74,11 +85,43 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV stripeCustomerId, updatedAt: new Date(), }) - .where(eq(schema.userFlux.userId, userId)) + .where(and( + eq(schema.userFlux.userId, userId), + isNull(schema.userFlux.deletedAt), + )) .returning() return updated }, + + /** + * Soft-delete the user's flux balance and drop the cached value from + * Redis. Does NOT touch `flux_transaction` — that ledger is preserved + * across user deletion for billing audit (and the table has no + * `deletedAt` column by design). + * + * Idempotent: `WHERE deletedAt IS NULL` skips an already-stamped row, + * `redis.del` is a no-op when the key is absent. + */ + async deleteAllForUser(userId: string) { + const now = new Date() + + const result = await db.update(schema.userFlux) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.userFlux.userId, userId), + isNull(schema.userFlux.deletedAt), + )) + .returning({ flux: schema.userFlux.flux }) + + // Drop the cached balance so any in-flight read does not see a + // ghost balance for the soft-deleted user. + await redis.del(userFluxRedisKey(userId)) + + logger + .withFields({ userId, clearedFlux: result[0]?.flux ?? 0 }) + .log('Flux balance soft-deleted and cache invalidated') + }, } } diff --git a/apps/server/src/services/providers.ts b/apps/server/src/services/providers.ts index 7bb20292f..0ee1d6a56 100644 --- a/apps/server/src/services/providers.ts +++ b/apps/server/src/services/providers.ts @@ -168,6 +168,27 @@ export function createProviderService(db: Database) { logger.withFields({ id }).log('Deleted system provider config') return result }, + + /** + * Soft-delete every `user_provider_configs` row owned by the user. + * Called from the user-deletion pipeline. System configs are not + * touched (they are not user-scoped). + * + * Idempotent: `WHERE deletedAt IS NULL` skips already-stamped rows. + */ + async deleteAllForUser(userId: string) { + const now = new Date() + + const result = await db.update(schema.userProviderConfigs) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.userProviderConfigs.ownerId, userId), + isNull(schema.userProviderConfigs.deletedAt), + )) + .returning({ id: schema.userProviderConfigs.id }) + + logger.withFields({ userId, count: result.length }).log('Provider configs soft-deleted for user') + }, } } diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/stripe.ts index 70cc20ea2..e3a35bf3e 100644 --- a/apps/server/src/services/stripe.ts +++ b/apps/server/src/services/stripe.ts @@ -1,14 +1,23 @@ +import type Stripe from 'stripe' + import type { Database } from '../libs/db' import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe' import { useLogger } from '@guiiai/logg' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, notInArray } from 'drizzle-orm' import * as schema from '../schemas/stripe' const logger = useLogger('stripe-service') -export function createStripeService(db: Database) { +// NOTICE: +// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose +// stripe_* rows persist for billing audit) are invisible to user-facing +// API. Webhooks that arrive after deletion still match by stripeCustomerId +// and re-upsert into the soft-deleted row — that's by design (the row +// remains deletedAt-set, but we capture the late event for accurate audit). +// See `apps/server/docs/ai-context/account-deletion.md`. +export function createStripeService(db: Database, stripe: Stripe | null) { return { // ---- Customer ---- @@ -26,11 +35,19 @@ export function createStripeService(db: Database) { async getCustomerByUserId(userId: string) { return db.query.stripeCustomer.findFirst({ - where: eq(schema.stripeCustomer.userId, userId), + where: and( + eq(schema.stripeCustomer.userId, userId), + isNull(schema.stripeCustomer.deletedAt), + ), }) }, async getCustomerByStripeId(stripeCustomerId: string) { + // NOTICE: NOT filtering by deletedAt — this lookup is by external + // Stripe id and is used by webhook handlers that need to reach + // soft-deleted archive rows for late events (cancellation receipts, + // final invoices arriving after account deletion). User-facing reads + // use getCustomerByUserId which DOES filter. return db.query.stripeCustomer.findFirst({ where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId), }) @@ -52,7 +69,10 @@ export function createStripeService(db: Database) { async getCheckoutSessionsByUserId(userId: string) { return db.query.stripeCheckoutSession.findMany({ - where: eq(schema.stripeCheckoutSession.userId, userId), + where: and( + eq(schema.stripeCheckoutSession.userId, userId), + isNull(schema.stripeCheckoutSession.deletedAt), + ), orderBy: (t, { desc }) => [desc(t.createdAt)], }) }, @@ -76,6 +96,7 @@ export function createStripeService(db: Database) { where: and( eq(schema.stripeSubscription.userId, userId), eq(schema.stripeSubscription.status, 'active'), + isNull(schema.stripeSubscription.deletedAt), ), orderBy: (t, { desc }) => [desc(t.createdAt)], }) @@ -97,10 +118,94 @@ export function createStripeService(db: Database) { async getInvoicesByUserId(userId: string) { return db.query.stripeInvoice.findMany({ - where: eq(schema.stripeInvoice.userId, userId), + where: and( + eq(schema.stripeInvoice.userId, userId), + isNull(schema.stripeInvoice.deletedAt), + ), orderBy: (t, { desc }) => [desc(t.createdAt)], }) }, + + /** + * Cancel the user's active Stripe subscription via the API and stamp every + * `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline + * (priority 10 — runs first because Stripe API cancellation has no + * rollback path). + * + * Idempotent on retry: subsequent calls find no `active` subs to cancel + * and the `WHERE deletedAt IS NULL` guard skips already-stamped rows. + * Stripe `subscriptions.cancel` itself is also idempotent per spec — + * cancelling an already-canceled sub returns 200. + * + * Cancellation is immediate, no proration, no refund — see + * `apps/server/docs/ai-context/account-deletion.md`. + */ + async deleteAllForUser(userId: string) { + // Cancel every subscription that is NOT already in a terminal state. + // Stripe's terminal statuses are `canceled` and `incomplete_expired`; + // anything else (`active`, `trialing`, `past_due`, `unpaid`, + // `incomplete`, `paused`) can still bill or transition into billing, + // so leaving them uncancelled would charge a deleted account. + // Stripe `subscriptions.cancel` is idempotent per spec — safe to + // call on any non-terminal status. + const cancellableSubs = await db.query.stripeSubscription.findMany({ + where: and( + eq(schema.stripeSubscription.userId, userId), + notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']), + isNull(schema.stripeSubscription.deletedAt), + ), + }) + + if (stripe && cancellableSubs.length > 0) { + for (const sub of cancellableSubs) { + try { + await stripe.subscriptions.cancel(sub.stripeSubscriptionId, { + prorate: false, + }) + logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription') + } + catch (err) { + logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription') + throw err + } + } + } + else if (!stripe && cancellableSubs.length > 0) { + logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted') + } + + const now = new Date() + + await db.update(schema.stripeSubscription) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.stripeSubscription.userId, userId), + isNull(schema.stripeSubscription.deletedAt), + )) + + await db.update(schema.stripeCheckoutSession) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.stripeCheckoutSession.userId, userId), + isNull(schema.stripeCheckoutSession.deletedAt), + )) + + await db.update(schema.stripeInvoice) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.stripeInvoice.userId, userId), + isNull(schema.stripeInvoice.deletedAt), + )) + + await db.update(schema.stripeCustomer) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + eq(schema.stripeCustomer.userId, userId), + isNull(schema.stripeCustomer.deletedAt), + )) + + logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user') + }, } } diff --git a/apps/server/src/services/tests/stripe.test.ts b/apps/server/src/services/tests/stripe.test.ts index 2c7da9b28..d1dd9b931 100644 --- a/apps/server/src/services/tests/stripe.test.ts +++ b/apps/server/src/services/tests/stripe.test.ts @@ -22,7 +22,7 @@ describe('stripeService', () => { }) beforeEach(async () => { - stripeService = createStripeService(db) + stripeService = createStripeService(db, null) // Clean all stripe tables between tests await db.delete(schema.stripeInvoice) diff --git a/apps/server/src/services/user-deletion/index.test.ts b/apps/server/src/services/user-deletion/index.test.ts new file mode 100644 index 000000000..8e3801460 --- /dev/null +++ b/apps/server/src/services/user-deletion/index.test.ts @@ -0,0 +1,121 @@ +import type { UserDeletionHandler } from './types' + +import { describe, expect, it, vi } from 'vitest' + +import { createUserDeletionService } from './index' + +function makeHandler(name: string, priority: number, body?: () => Promise | void): UserDeletionHandler { + return { + name, + priority, + softDelete: vi.fn(async () => { + await body?.() + }), + } +} + +describe('createUserDeletionService', () => { + describe('register', () => { + it('rejects duplicate handler names', () => { + const service = createUserDeletionService() + service.register(makeHandler('flux', 20)) + + expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/) + }) + + it('keeps handlers in ascending priority regardless of registration order', async () => { + const service = createUserDeletionService() + const calls: string[] = [] + service.register(makeHandler('characters', 30, () => { + calls.push('characters') + })) + service.register(makeHandler('stripe', 10, () => { + calls.push('stripe') + })) + service.register(makeHandler('flux', 20, () => { + calls.push('flux') + })) + + await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }) + + // @example + // register order: characters(30) -> stripe(10) -> flux(20) + // execution order: stripe(10) -> flux(20) -> characters(30) + expect(calls).toEqual(['stripe', 'flux', 'characters']) + }) + }) + + describe('softDeleteAll', () => { + it('passes the user id and reason to every handler', async () => { + const service = createUserDeletionService() + const a = makeHandler('a', 10) + const b = makeHandler('b', 20) + service.register(a) + service.register(b) + + await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' }) + + expect(a.softDelete).toHaveBeenCalledTimes(1) + expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' })) + expect(b.softDelete).toHaveBeenCalledTimes(1) + expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' })) + }) + + it('aborts on first handler error and skips later handlers', async () => { + const service = createUserDeletionService() + const earlyOk = makeHandler('a', 10) + const failing = makeHandler('b', 20, () => { + throw new Error('stripe API down') + }) + const lateNeverRuns = makeHandler('c', 30) + + service.register(earlyOk) + service.register(failing) + service.register(lateNeverRuns) + + await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })) + .rejects + .toThrow('stripe API down') + + expect(earlyOk.softDelete).toHaveBeenCalledTimes(1) + expect(failing.softDelete).toHaveBeenCalledTimes(1) + expect(lateNeverRuns.softDelete).not.toHaveBeenCalled() + }) + + it('runs handlers serially (next starts only after previous resolves)', async () => { + const service = createUserDeletionService() + const order: string[] = [] + + service.register({ + name: 'slow', + priority: 10, + softDelete: async () => { + order.push('slow:start') + await new Promise(r => setTimeout(r, 5)) + order.push('slow:end') + }, + }) + service.register({ + name: 'fast', + priority: 20, + softDelete: async () => { + order.push('fast:start') + order.push('fast:end') + }, + }) + + await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }) + + // @example + // serial execution: slow:start -> slow:end -> fast:start -> fast:end + // (NOT slow:start -> fast:start -> slow:end -> fast:end which would + // indicate parallelism) + expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end']) + }) + + it('runs no handlers gracefully when registry is empty', async () => { + const service = createUserDeletionService() + await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/server/src/services/user-deletion/index.ts b/apps/server/src/services/user-deletion/index.ts new file mode 100644 index 000000000..4a367092e --- /dev/null +++ b/apps/server/src/services/user-deletion/index.ts @@ -0,0 +1,77 @@ +import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types' + +import { useLogger } from '@guiiai/logg' + +export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types' + +/** + * Build an empty deletion-service registry. + * + * Use when: + * - Composing the server in `app.ts` — wire one instance and `register()` + * each business handler at composition time. + * + * Returns: + * - A registry whose `softDeleteAll` walks handlers in ascending `priority` + * and aborts on the first throw. Successful handlers are NOT rolled back — + * each handler's writes must be idempotent. + * + * Call stack: + * + * better-auth `/delete-user/callback` + * -> `user.deleteUser.beforeDelete` (libs/auth.ts) + * -> {@link UserDeletionService.softDeleteAll} + * -> handler.softDelete (per registered module) + * + * Failure model: a thrown error from any handler aborts before + * `internalAdapter.deleteUser`, leaving the user row intact. The next retry + * is expected to re-run already-completed handlers as no-ops. + */ +export function createUserDeletionService(): UserDeletionService { + const handlers: UserDeletionHandler[] = [] + const names = new Set() + + const logger = useLogger('user-deletion').useGlobalConfig() + + return { + register(handler) { + if (names.has(handler.name)) + throw new Error(`Duplicate user-deletion handler name: ${handler.name}`) + + names.add(handler.name) + handlers.push(handler) + // Resort on every insert so post-boot registrations stay ordered. + handlers.sort((a, b) => a.priority - b.priority) + }, + + async softDeleteAll({ userId, reason }) { + const ctx = { + userId, + reason: reason as UserDeletionReason, + logger, + } + + logger.withFields({ userId, reason, handlerCount: handlers.length }).log('starting user deletion') + + for (const handler of handlers) { + const startedAt = Date.now() + + try { + await handler.softDelete(ctx) + logger + .withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt }) + .log('handler completed') + } + catch (err) { + logger + .withError(err) + .withFields({ handler: handler.name, userId, durationMs: Date.now() - startedAt }) + .error('handler failed; aborting deletion pipeline') + throw err + } + } + + logger.withFields({ userId, reason }).log('user deletion handlers completed') + }, + } +} diff --git a/apps/server/src/services/user-deletion/service-deletion.test.ts b/apps/server/src/services/user-deletion/service-deletion.test.ts new file mode 100644 index 000000000..e88866d17 --- /dev/null +++ b/apps/server/src/services/user-deletion/service-deletion.test.ts @@ -0,0 +1,282 @@ +import type { Database } from '../../libs/db' + +import { eq } from 'drizzle-orm' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createCharacterService } from '../characters' +import { createChatService } from '../chats' +import { createFluxService } from '../flux' +import { createProviderService } from '../providers' + +import * as schema from '../../schemas' + +function fakeRedis() { + const map = new Map() + return { + get: vi.fn(async (k: string) => map.get(k) ?? null), + set: vi.fn(async (k: string, v: string) => { + map.set(k, v) + return 'OK' + }), + del: vi.fn(async (k: string) => { + const had = map.has(k) + map.delete(k) + return had ? 1 : 0 + }), + } as any +} + +function fakeConfigKV() { + return { + get: vi.fn(async () => undefined), + getOrThrow: vi.fn(async () => 0), + set: vi.fn(async () => {}), + } as any +} + +describe('fluxService.deleteAllForUser', () => { + let db: Database + + beforeAll(async () => { + db = await mockDB(schema) + }) + + it('marks userFlux.deletedAt and invalidates Redis cache', async () => { + await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' }) + await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 }) + + const redis = fakeRedis() + const service = createFluxService(db, redis, fakeConfigKV()) + await service.deleteAllForUser('u-flux-1') + + const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') }) + expect(row?.deletedAt).toBeInstanceOf(Date) + expect(redis.del).toHaveBeenCalledTimes(1) + expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1')) + }) + + it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => { + await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' }) + await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 }) + + const redis = fakeRedis() + const service = createFluxService(db, redis, fakeConfigKV()) + + await service.deleteAllForUser('u-flux-2') + const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt + + // Second invocation: WHERE deletedAt IS NULL filters out the + // already-stamped row, so deletedAt does not change. + await service.deleteAllForUser('u-flux-2') + const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt + + expect(secondStamp).toEqual(firstStamp) + }) +}) + +describe('providerService.deleteAllForUser', () => { + let db: Database + + beforeAll(async () => { + db = await mockDB(schema) + }) + + it('marks every userProviderConfigs row owned by the user', async () => { + await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' }) + await db.insert(schema.userProviderConfigs).values([ + { ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' }, + { ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' }, + ]) + + const service = createProviderService(db) + await service.deleteAllForUser('u-prov-1') + + const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') }) + expect(rows).toHaveLength(2) + rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date)) + }) + + it('does not touch other users rows', async () => { + await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' }) + await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' }) + + const service = createProviderService(db) + await service.deleteAllForUser('u-prov-1') + + const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') }) + expect(otherRow?.deletedAt).toBeNull() + }) +}) + +describe('characterService.deleteAllForUser', () => { + let db: Database + + beforeAll(async () => { + db = await mockDB(schema) + }) + + it('soft-deletes characters where the user is owner OR creator', async () => { + await db.insert(schema.user).values([ + { id: 'u-char-1', name: 'C1', email: 'c1@example.com' }, + { id: 'u-char-2', name: 'C2', email: 'c2@example.com' }, + ]) + await db.insert(schema.character).values([ + { id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' }, + { id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' }, + { id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' }, + ]) + + const service = createCharacterService(db) + await service.deleteAllForUser('u-char-1') + + const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') }) + const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') }) + const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') }) + + expect(owner?.deletedAt).toBeInstanceOf(Date) + expect(creator?.deletedAt).toBeInstanceOf(Date) + expect(other?.deletedAt).toBeNull() + }) + + it('soft-deletes the user likes and bookmarks', async () => { + await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' }) + await db.insert(schema.character).values({ + id: 'char-z', + version: '1', + coverUrl: '', + creatorId: 'u-char-3', + ownerId: 'u-char-3', + characterId: 'cid-z', + }) + await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' }) + await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' }) + + const service = createCharacterService(db) + await service.deleteAllForUser('u-char-3') + + const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') }) + const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') }) + + expect(like?.deletedAt).toBeInstanceOf(Date) + expect(bookmark?.deletedAt).toBeInstanceOf(Date) + }) +}) + +describe('chatService.deleteAllForUser', () => { + let db: Database + + beforeAll(async () => { + db = await mockDB(schema) + }) + + it('soft-deletes chats the user is a member of', async () => { + await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' }) + await db.insert(schema.chats).values([ + { id: 'chat-mine', type: 'private', title: 'mine' }, + { id: 'chat-other', type: 'private', title: 'other' }, + ]) + await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' }) + + const service = createChatService(db) + await service.deleteAllForUser('u-chat-1') + + const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') }) + const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') }) + + expect(mine?.deletedAt).toBeInstanceOf(Date) + expect(other?.deletedAt).toBeNull() + }) + + it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => { + // Two users in a shared group chat. When user A is deleted, the chat + // row must survive for user B; only A's chat_members row goes. + await db.insert(schema.user).values([ + { id: 'u-grp-a', name: 'A', email: 'grpa@example.com' }, + { id: 'u-grp-b', name: 'B', email: 'grpb@example.com' }, + ]) + await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' }) + await db.insert(schema.chatMembers).values([ + { chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' }, + { chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' }, + ]) + + const service = createChatService(db) + await service.deleteAllForUser('u-grp-a') + + const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') }) + expect(chatRow?.deletedAt).toBeNull() // chat survives + + const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') }) + expect(remainingMembers).toHaveLength(1) + expect(remainingMembers[0]?.userId).toBe('u-grp-b') + }) + + it('preserves the user messages inside group chats so other members keep conversation context', async () => { + // Anonymization-by-design: in a group chat, user A's messages must NOT + // be soft-deleted on account deletion — that would corrupt B's history. + // The senderId stays as the (now-orphan) user.id string; the UI renders + // it as "Deleted User" once it cannot resolve the id to a real user. + await db.insert(schema.user).values([ + { id: 'u-anon-a', name: 'A', email: 'anona@example.com' }, + { id: 'u-anon-b', name: 'B', email: 'anonb@example.com' }, + ]) + await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' }) + await db.insert(schema.chatMembers).values([ + { chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' }, + { chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' }, + ]) + await db.insert(schema.messages).values([ + { id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] }, + { id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] }, + ]) + + const service = createChatService(db) + await service.deleteAllForUser('u-anon-a') + + // A's message stays alive; senderId still points at the now-orphan user.id string. + const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') }) + expect(aMsg?.deletedAt).toBeNull() + expect(aMsg?.senderId).toBe('u-anon-a') + expect(aMsg?.content).toBe('hi from A') + + // B's message obviously untouched. + const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') }) + expect(bMsg?.deletedAt).toBeNull() + }) + + it('soft-deletes messages the user sent in private/bot chats', async () => { + await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' }) + await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' }) + await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' }) + await db.insert(schema.messages).values([ + { + id: 'msg-mine', + chatId: 'chat-msg', + senderId: 'u-chat-2', + role: 'user', + content: 'hi', + mediaIds: [], + stickerIds: [], + }, + { + id: 'msg-other', + chatId: 'chat-msg', + senderId: 'someone-else', + role: 'assistant', + content: 'hello', + mediaIds: [], + stickerIds: [], + }, + ]) + + const service = createChatService(db) + await service.deleteAllForUser('u-chat-2') + + const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') }) + const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') }) + + expect(mine?.deletedAt).toBeInstanceOf(Date) + expect(other?.deletedAt).toBeNull() + }) +}) diff --git a/apps/server/src/services/user-deletion/types.ts b/apps/server/src/services/user-deletion/types.ts new file mode 100644 index 000000000..080c30b30 --- /dev/null +++ b/apps/server/src/services/user-deletion/types.ts @@ -0,0 +1,89 @@ +import type { Logger } from '@guiiai/logg' + +/** + * Reason a user deletion is being requested. Recorded in logs and surfaced + * to handlers so they can branch (e.g. compliance erase vs. user-initiated). + * + * - `user-requested`: triggered by the user via better-auth `/delete-user/callback`. + * - `admin`: triggered by an admin tool (not yet implemented). + * - `compliance`: triggered by automated GDPR / data-retention workflow (not yet implemented). + */ +export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance' + +/** + * Context passed to every {@link UserDeletionHandler} invocation. + * + * Use when: + * - Implementing a new business handler — read `userId` to scope your soft-delete writes. + * - Logging within a handler — use the provided `logger` so entries share the deletion correlation context. + */ +export interface UserDeletionContext { + /** The user being deleted. Handlers MUST scope their writes to this id. */ + userId: string + /** Why the deletion was triggered. */ + reason: UserDeletionReason + /** Pre-scoped logger for handler diagnostics. */ + logger: Logger +} + +/** + * A registered participant in the account-deletion pipeline. + * + * Each business module that owns user-scoped tables registers one of these + * with the {@link UserDeletionService}. Handlers run sequentially in + * ascending `priority` order; a thrown error aborts the whole pipeline so + * better-auth's hard-delete of the user row never runs (the user is left + * intact and the operation can be retried idempotently). + * + * @example + * createUserDeletionService().register({ + * name: 'flux', + * priority: 20, + * async softDelete({ userId }) { + * await db.update(userFlux).set({ deletedAt: new Date() }).where(eq(userFlux.userId, userId)) + * }, + * }) + */ +export interface UserDeletionHandler { + /** Stable identifier used for logs, metrics, and duplicate-registration checks. */ + name: string + /** + * Lower runs first. Conventions: + * - 10: external side-effects without rollback (Stripe API cancel) + * - 20: financial / cache state (Flux balance, Redis invalidation) + * - 30: pure DB soft-delete (providers, characters, chats) + * + * @default 30 + */ + priority: number + /** + * Mark business records as deleted. MUST be idempotent — the deletion + * pipeline retries by re-issuing the entire request, and Stripe / Postgres + * already deduplicate on subsequent calls. Throw to abort the pipeline. + */ + softDelete: (ctx: UserDeletionContext) => Promise +} + +/** + * Coordinator for account deletion across business modules. + * + * Use when: + * - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`. + * - Implementing an admin-triggered deletion path (future). + * + * Expects: + * - All handlers are registered at app-composition time before the first + * request hits `beforeDelete`. Late registration is allowed but discouraged. + */ +export interface UserDeletionService { + /** + * Register a handler. Throws if `handler.name` is already registered — + * names must be unique so logs and metrics can attribute work cleanly. + */ + register: (handler: UserDeletionHandler) => void + /** + * Run every registered handler in priority order. Returns when all + * handlers complete, or throws the first handler error and stops. + */ + softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise +} diff --git a/apps/ui-server-auth/src/pages/delete-account.vue b/apps/ui-server-auth/src/pages/delete-account.vue new file mode 100644 index 000000000..c004c1664 --- /dev/null +++ b/apps/ui-server-auth/src/pages/delete-account.vue @@ -0,0 +1,74 @@ + + + + + +meta: + layout: plain + diff --git a/packages/i18n/src/locales/en/server/auth.yaml b/packages/i18n/src/locales/en/server/auth.yaml index 63b53c8fa..5a161a22e 100644 --- a/packages/i18n/src/locales/en/server/auth.yaml +++ b/packages/i18n/src/locales/en/server/auth.yaml @@ -93,6 +93,14 @@ forgotPassword: sent: If {email} matches an account, a reset link is on the way. error: fallback: We could not send the reset email. +deleteAccount: + title: + success: Account deleted + failed: Could not delete account + message: + success: Your account has been deleted. + action: + closeTab: You can close this tab. resetPassword: title: default: Set a new password @@ -132,7 +140,7 @@ profile: placeholder: How others see you avatar: altText: Profile avatar - gravatarNotice: Showing your Gravatar avatar — based on a hash of your email. + gravatarNotice: Avatar from Gravatar. gravatarLink: Manage on gravatar.com password: currentLabel: Current password diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 864e9b5e8..59c448592 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -169,7 +169,7 @@ pages: label: Display name placeholder: How others see you avatar: - gravatarNotice: Showing your Gravatar avatar — based on a hash of your email. + gravatarNotice: Avatar from Gravatar. gravatarLink: Manage on gravatar.com action: save: Save changes @@ -229,9 +229,21 @@ pages: description: Irreversible account actions live here. deleteAccount: title: Delete account - description: Permanently remove your account, sessions, and personal data. - action: Delete - notAvailable: Coming soon — contact support to delete your account today. + description: Permanently delete your account and personal data. + action: Delete account + modal: + title: Delete account? + warning: This cannot be undone. Active subscription will be canceled, Flux balance cleared. + confirmEmail: + label: Type your email to confirm + placeholder: '' + mismatch: Email does not match. + confirm: Send confirmation email + cancel: Cancel + message: + emailSent: Check {email} for the deletion link. + error: + fallback: Could not start deletion. Try again later. card: activate: Activate active: Active diff --git a/packages/i18n/src/locales/zh-Hans/server/auth.yaml b/packages/i18n/src/locales/zh-Hans/server/auth.yaml index 6a1dc1917..4515f8ed8 100644 --- a/packages/i18n/src/locales/zh-Hans/server/auth.yaml +++ b/packages/i18n/src/locales/zh-Hans/server/auth.yaml @@ -68,6 +68,14 @@ signIn: terms: 服务条款 and: 和 privacy: 隐私政策 +deleteAccount: + title: + success: 账号已删除 + failed: 无法删除账号 + message: + success: 你的账号已被删除。 + action: + closeTab: 你可以关闭此页面。 profile: title: 账号资料 description: 管理你的头像、显示名、密码和已绑定的社交账号。 @@ -88,8 +96,8 @@ profile: placeholder: 别人看到的名字 avatar: altText: 用户头像 - gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。 - gravatarLink: 前往 gravatar.com 管理 + gravatarNotice: 头像来自 Gravatar。 + gravatarLink: 在 gravatar.com 管理 password: currentLabel: 当前密码 currentPlaceholder: 输入当前密码 diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index a2c97f938..62fcf5fdb 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -160,8 +160,8 @@ pages: label: 显示名 placeholder: 别人看到的名字 avatar: - gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。 - gravatarLink: 前往 gravatar.com 管理 + gravatarNotice: 头像来自 Gravatar。 + gravatarLink: 在 gravatar.com 管理 action: save: 保存修改 message: @@ -220,9 +220,21 @@ pages: description: 这里的操作不可撤销,请谨慎使用。 deleteAccount: title: 删除账号 - description: 永久删除你的账号、会话与个人数据。 - action: 删除 - notAvailable: 即将上线——如需立即删除账号请联系支持。 + description: 永久删除你的账号与个人数据。 + action: 删除账号 + modal: + title: 确认删除账号? + warning: 此操作不可撤销。订阅将被取消,Flux 余额将清空。 + confirmEmail: + label: 输入邮箱以确认 + placeholder: '' + mismatch: 邮箱不匹配。 + confirm: 发送确认邮件 + cancel: 取消 + message: + emailSent: 删除链接已发送至 {email}。 + error: + fallback: 无法发起删除,请稍后重试。 card: activate: 激活 active: 已激活 diff --git a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue index db271f9a2..f235d4d85 100644 --- a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue +++ b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue @@ -7,6 +7,7 @@ import { SERVER_URL } from '@proj-airi/stage-ui/libs/server' import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' import { Button, FieldInput } from '@proj-airi/ui' import { storeToRefs } from 'pinia' +import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui' import { computed, reactive, ref, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { RouterLink } from 'vue-router' @@ -297,6 +298,80 @@ async function handleSendSetPasswordLink() { setPasswordLoading.value = false } } + +// ---- Delete account ---- +// +// Two-step flow: +// (1) Click "Delete account" -> open a reka-ui Dialog with a focus-trap and +// overlay so the destructive action is unambiguously modal. Inside the +// dialog the user retypes their email; we only enable the submit button +// when the entered value matches `userEmail` exactly. This is the same +// irreversible-action pattern GitHub / Linear use. +// (2) On confirm, call `authClient.deleteUser({ callbackURL })`. better-auth +// emails a single-use link; clicking it runs the soft-delete handlers +// server-side, hard-deletes the auth tables, then redirects the browser +// to `callbackURL`. We point the callback at ui-server-auth's success +// page on the API server origin — stage-web and stage-tamagotchi do not +// own a dedicated post-delete route, and ui-server-auth is reachable +// from every embedding app. +const deleteDialogOpen = ref(false) +const deleteSent = shallowRef(false) +const deleteForm = reactive({ confirmEmail: '' }) +const deleteLoading = shallowRef(false) +const deleteError = shallowRef(null) + +const deleteEmailMatches = computed(() => { + const target = userEmail.value?.trim().toLowerCase() ?? '' + return target.length > 0 && deleteForm.confirmEmail.trim().toLowerCase() === target +}) + +function openDeleteDialog() { + deleteForm.confirmEmail = '' + deleteError.value = null + deleteDialogOpen.value = true +} + +// Reset transient form state whenever the dialog closes (cancel button, ESC, +// overlay click). We deliberately keep `deleteSent` outside this reset so the +// success message under the Danger Zone stays visible after the dialog is +// dismissed by the user. +watch(deleteDialogOpen, (open) => { + if (!open) { + deleteForm.confirmEmail = '' + deleteError.value = null + } +}) + +async function handleConfirmDelete(event: Event) { + event.preventDefault() + if (deleteLoading.value || !deleteEmailMatches.value) + return + + deleteError.value = null + deleteLoading.value = true + + try { + // The success page lives on the API-server origin (shared + // ui-server-auth bundle). It tells the user the deletion completed + // and asks them to close the tab — there is no "back to home" + // because the API server has no reliable way to know which calling + // app origin (stage-web / stage-tamagotchi / stage-pocket) the + // request came from. + const callbackURL = new URL('/auth/delete-account', SERVER_URL).toString() + const { error } = await authClient.deleteUser({ callbackURL }) + if (error) + throw new Error(error.message ?? 'deleteUser failed') + + deleteSent.value = true + deleteDialogOpen.value = false + } + catch (error) { + deleteError.value = errorMessageFrom(error) ?? t('settings.pages.account.danger.deleteAccount.error.fallback') + } + finally { + deleteLoading.value = false + } +}