forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
packages/*/node_modules
|
||||
scripts/import-pocketbase/node_modules
|
||||
|
||||
dist
|
||||
dist-admin
|
||||
dist-public
|
||||
apps/*/dist
|
||||
packages/*/dist
|
||||
scripts/import-pocketbase/dist
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
pb_data
|
||||
pb_public
|
||||
pb_export
|
||||
.git
|
||||
.qoder
|
||||
.codex-backups
|
||||
logs
|
||||
*.log
|
||||
42
.env.example
Normal file
42
.env.example
Normal file
@@ -0,0 +1,42 @@
|
||||
# 阿里云短信服务配置(脚本/服务端使用,前端不读取此文件)
|
||||
# 复制此文件为 .env 并填写实际值
|
||||
#
|
||||
# 生产环境说明:
|
||||
# - 学生端 https://tiku.tjszsb.com
|
||||
# - 超管后台 https://tikuguanli.tjszsb.com
|
||||
# - 服务器 39.107.64.207(PocketBase 运行于 127.0.0.1:8090,由 Nginx 反代)
|
||||
#
|
||||
# 前端(Vite)运行时 PocketBase 地址会根据 window.location 自动判断:
|
||||
# - 本地开发 → http://127.0.0.1:8090
|
||||
# - 线上部署 → 与页面同源(Nginx 代理到后端)
|
||||
# 因此前端代码无需配置服务器 IP;本文件仅供 Node 脚本使用。
|
||||
|
||||
# PocketBase 地址(本地脚本连接用)
|
||||
POCKETBASE_URL=http://127.0.0.1:8090
|
||||
|
||||
# 阿里云 AccessKey
|
||||
ALIYUN_ACCESS_KEY_ID=your_access_key_id_here
|
||||
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret_here
|
||||
|
||||
# 阿里云短信配置
|
||||
ALIYUN_SMS_SIGN=天津专升本
|
||||
ALIYUN_SMS_TEMPLATE=SMS_123456789
|
||||
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
|
||||
# 新 Supabase/PostgreSQL 重构 API
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
DEFAULT_TENANT_SLUG=master
|
||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
|
||||
# 认证迁移期配置:生产环境必须替换为高强度随机值
|
||||
AUTH_SMS_PROVIDER=mock
|
||||
AUTH_CODE_PEPPER=replace_with_a_long_random_secret
|
||||
AUTH_SESSION_SECRET=replace_with_another_long_random_secret
|
||||
AUTH_CODE_TTL_SECONDS=300
|
||||
AUTH_SMS_COOLDOWN_SECONDS=60
|
||||
AUTH_SESSION_TTL_SECONDS=604800
|
||||
|
||||
# 迁移期平台管理 API Key。生产环境应替换为平台管理员 JWT/服务端会话。
|
||||
PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key
|
||||
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# ✅ 环境变量文件(包含敏感信息)
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# ✅ 租户配置(每个部署实例独立维护)
|
||||
src/tenant.config.ts
|
||||
|
||||
# ✅ 卫星站同步配置(含管理员密码)
|
||||
scripts/satellite/sync-config.json
|
||||
|
||||
# ✅ 构建产物
|
||||
/dist-admin/
|
||||
/dist-public/
|
||||
|
||||
# ✅ IDE / AI 工具配置
|
||||
.qoder/
|
||||
.codex-backups/
|
||||
|
||||
# ✅ 补丁与压缩包
|
||||
*.patch
|
||||
*.tar.gz
|
||||
|
||||
# ✅ 临时/对比文件
|
||||
stash_comparison.txt
|
||||
stash_diff.txt
|
||||
|
||||
# ✅ 备份文件
|
||||
*.backup
|
||||
|
||||
# ✅ 卫星站安装包
|
||||
卫星站安装包/
|
||||
|
||||
# ✅ 参考资料(非源码)
|
||||
参考/
|
||||
新UI参考/
|
||||
|
||||
# ✅ Electron 打包产物
|
||||
/release/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# ✅ 部署脚本本地缓存(记录 package-lock 哈希)
|
||||
.deploy-cache/
|
||||
|
||||
# ✅ PocketBase 运行期产出(不入 git)
|
||||
pb_data/
|
||||
pb_public/
|
||||
pocketbase
|
||||
pocketbase.exe
|
||||
|
||||
# Supabase local CLI state
|
||||
supabase/.branches/
|
||||
supabase/.temp/
|
||||
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# tiku-supabase
|
||||
|
||||
PocketBase/SQLite question-bank product rebuilt as a Supabase/PostgreSQL multi-tenant SaaS backend.
|
||||
|
||||
This repository is the new reconstruction workspace for the commercial SaaS version. The old PocketBase/React project remains as the migration reference in the original workspace, but this Git repository should focus on the new backend, database schema, importer, and delivery documentation.
|
||||
|
||||
## Current Status
|
||||
|
||||
Updated: 2026-06-21
|
||||
|
||||
Implemented and locally verified:
|
||||
|
||||
- Supabase/PostgreSQL multi-tenant schema, RLS policies, indexes, and triggers.
|
||||
- Node.js business API under `apps/api`, shared by future H5, Taro mini program, and admin clients.
|
||||
- Tenant admin APIs for branding, domain, settings, payment accounts, login providers, secrets, marketing content, activation codes, coupons, members, permissions, and audit logs.
|
||||
- Tenant content APIs for questions, videos, scorelines, vocabulary, handbook content, content assets, and question JSON import.
|
||||
- Student APIs for catalog, practice sessions, answers, wrong questions, favorites, vocabulary progress, profile, scorelines, question videos, orders, entitlements, activation-code redemption, and content asset download.
|
||||
- Platform admin APIs for tenants, SaaS plans, subscriptions, invoices, payments, usage records, and billing profiles.
|
||||
- Referral/CRM growth APIs for invite codes, first-binding lead protection, sales/agent teams, CRM config, and CRM queue.
|
||||
- PocketBase schema/data importer scaffold with validation.
|
||||
- Local Supabase reset, smoke seed, API integration tests, and refactor check command.
|
||||
|
||||
Still not production complete:
|
||||
|
||||
- Supabase Auth/JWT and full production RLS validation must replace migration-period headers.
|
||||
- Real SMS/OAuth/payment provider adapters are still pending.
|
||||
- Real OSS/COS/Supabase Storage signed upload/download adapters are still pending.
|
||||
- Excel/CSV import, vocabulary/handbook/scoreline/video import, and async import worker are pending.
|
||||
- Taro frontend scaffold is pending.
|
||||
|
||||
See:
|
||||
|
||||
- `docs/refactor/implementation-status.md`
|
||||
- `docs/refactor/backend-progress.md`
|
||||
- `docs/refactor/blueprint-coverage.md`
|
||||
- `docs/refactor/api-structure.md`
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```text
|
||||
apps/api/ Node.js business API
|
||||
packages/config/ Shared config defaults
|
||||
packages/db/ PostgreSQL pool/query helpers
|
||||
packages/domain/ Domain constants and shared types
|
||||
supabase/migrations/ PostgreSQL schema, RLS, indexes, triggers
|
||||
supabase/seed.sql Minimal tenant seed
|
||||
scripts/import-pocketbase/ PocketBase schema/data importer and validator
|
||||
scripts/smoke-seed.js Local integration-test seed data
|
||||
scripts/api-integration-test.js
|
||||
docs/refactor/ Reconstruction architecture and progress docs
|
||||
docker-compose.api.yml API container compose file
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Node.js 20+
|
||||
- Docker Desktop
|
||||
- Supabase CLI
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run supabase:start
|
||||
npm run supabase:reset
|
||||
npm run db:smoke-seed
|
||||
npm run dev:api
|
||||
```
|
||||
|
||||
Default local database:
|
||||
|
||||
```text
|
||||
postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
```
|
||||
|
||||
API defaults to:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Run the full backend reconstruction check:
|
||||
|
||||
```bash
|
||||
npm run check:refactor
|
||||
```
|
||||
|
||||
This runs:
|
||||
|
||||
- API TypeScript check
|
||||
- PocketBase importer TypeScript check
|
||||
- PocketBase import validation
|
||||
- smoke seed
|
||||
- API build
|
||||
- local API integration test
|
||||
|
||||
Useful individual commands:
|
||||
|
||||
```bash
|
||||
npm run check:api
|
||||
npm run check:importer
|
||||
npm run pb:import:validate
|
||||
npm run test:api
|
||||
```
|
||||
|
||||
## API Modules
|
||||
|
||||
Current API feature folders:
|
||||
|
||||
```text
|
||||
apps/api/src/features/
|
||||
auth/
|
||||
catalog/
|
||||
commerce/
|
||||
health/
|
||||
learning/
|
||||
platform-admin/
|
||||
profile/
|
||||
referral/
|
||||
scoreline/
|
||||
tenant/
|
||||
tenant-admin/
|
||||
tenant-content/
|
||||
video/
|
||||
```
|
||||
|
||||
Migration-period API context:
|
||||
|
||||
- `x-tenant-id`
|
||||
- `x-user-id`
|
||||
- `x-platform-admin-key`
|
||||
|
||||
Production must replace these with Supabase Auth/JWT/server-side sessions.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Tenant public payment/login config must not contain secrets.
|
||||
- Secrets go to `app_private.tenant_secrets` or future production KMS/Vault.
|
||||
- Asset download must go through API authorization and signed URL generation.
|
||||
- Content import must write job/item/issue records before final import.
|
||||
- Payment webhooks must be idempotent before production use.
|
||||
|
||||
## Latest Verified Check
|
||||
|
||||
Last local verification:
|
||||
|
||||
```text
|
||||
npm run supabase:reset
|
||||
npm run check:refactor
|
||||
```
|
||||
|
||||
Result: passed.
|
||||
4
apps/api/.env.example
Normal file
4
apps/api/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
PORT=8787
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
DEFAULT_TENANT_SLUG=master
|
||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
34
apps/api/Dockerfile
Normal file
34
apps/api/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json tsconfig.json ./
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages ./packages
|
||||
RUN npm run build:api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
|
||||
EXPOSE 8787
|
||||
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]
|
||||
729
apps/api/package-lock.json
generated
Normal file
729
apps/api/package-lock.json
generated
Normal file
@@ -0,0 +1,729 @@
|
||||
{
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/api/package.json
Normal file
21
apps/api/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/apps/api/src/server.js",
|
||||
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
37
apps/api/src/core/config.ts
Normal file
37
apps/api/src/core/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
|
||||
|
||||
export interface ApiConfig {
|
||||
nodeEnv: string;
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
defaultTenantSlug: string;
|
||||
corsOrigins: string[];
|
||||
authCodePepper: string;
|
||||
authSessionSecret: string;
|
||||
authSmsProvider: string;
|
||||
authCodeTtlSeconds: number;
|
||||
authSmsCooldownSeconds: number;
|
||||
authSessionTtlSeconds: number;
|
||||
platformAdminApiKey: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
loadDotenv();
|
||||
|
||||
const isProduction = envString('NODE_ENV', 'development') === 'production';
|
||||
|
||||
export const config: ApiConfig = {
|
||||
nodeEnv: envString('NODE_ENV', 'development'),
|
||||
port: envNumber('PORT', 8787),
|
||||
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
|
||||
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
|
||||
corsOrigins: envList('CORS_ORIGIN', '*'),
|
||||
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
|
||||
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
|
||||
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
|
||||
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
|
||||
isProduction,
|
||||
};
|
||||
35
apps/api/src/core/db.ts
Normal file
35
apps/api/src/core/db.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type pg from 'pg';
|
||||
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../../packages/db/src/index.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const pool = createPool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
return runQuery<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
return runQueryOne<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
export async function transaction<T>(callback: (client: pg.PoolClient) => Promise<T>) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await callback(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
68
apps/api/src/core/http.ts
Normal file
68
apps/api/src/core/http.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { config } from './config.js';
|
||||
|
||||
export interface RequestContext {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RequestContext) => Promise<unknown>;
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
message: string,
|
||||
public readonly code = 'HTTP_ERROR',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function getHeader(req: IncomingMessage, name: string): string {
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
export function applyCors(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = getHeader(req, 'origin');
|
||||
const allowAll = config.corsOrigins.includes('*');
|
||||
if (allowAll) {
|
||||
res.setHeader('access-control-allow-origin', '*');
|
||||
} else if (origin && config.corsOrigins.includes(origin)) {
|
||||
res.setHeader('access-control-allow-origin', origin);
|
||||
res.setHeader('vary', 'origin');
|
||||
}
|
||||
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
|
||||
}
|
||||
|
||||
export function routeKey(method: string | undefined, pathname: string) {
|
||||
return `${method || 'GET'} ${pathname}`;
|
||||
}
|
||||
|
||||
export function publicErrorBody(error: unknown) {
|
||||
if (error instanceof HttpError) {
|
||||
return {
|
||||
statusCode: error.statusCode,
|
||||
body: { error: error.message, code: error.code },
|
||||
};
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: config.isProduction ? 'Internal server error' : message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
87
apps/api/src/core/request.ts
Normal file
87
apps/api/src/core/request.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { config } from './config.js';
|
||||
import { getHeader, HttpError, type RequestContext } from './http.js';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
const userId =
|
||||
getHeader(ctx.req, 'x-user-id') ||
|
||||
ctx.url.searchParams.get('userId') ||
|
||||
(typeof body?.userId === 'string' ? body.userId : '');
|
||||
|
||||
if (!userId) {
|
||||
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.trunc(value), max);
|
||||
}
|
||||
|
||||
export function stringParam(ctx: RequestContext, name: string) {
|
||||
return ctx.url.searchParams.get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
export async function readJsonBody(ctx: RequestContext): Promise<JsonObject> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of ctx.req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
|
||||
}
|
||||
return parsed as JsonObject;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredString(body: JsonObject, key: string) {
|
||||
const value = body[key];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function optionalString(body: JsonObject, key: string) {
|
||||
const value = body[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function optionalInteger(body: JsonObject, key: string, fallback: number) {
|
||||
const value = Number(body[key] ?? fallback);
|
||||
return Number.isFinite(value) ? Math.trunc(value) : fallback;
|
||||
}
|
||||
|
||||
export function optionalStringArray(body: JsonObject, key: string): string[] {
|
||||
const value = body[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function requirePlatformAdmin(ctx: RequestContext) {
|
||||
const provided = getHeader(ctx.req, 'x-platform-admin-key');
|
||||
if (!provided || provided !== config.platformAdminApiKey) {
|
||||
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
|
||||
}
|
||||
}
|
||||
44
apps/api/src/core/router.ts
Normal file
44
apps/api/src/core/router.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Handler } from './http.js';
|
||||
import { routeKey } from './http.js';
|
||||
import { authRoutes } from '../features/auth/index.js';
|
||||
import { catalogRoutes } from '../features/catalog/index.js';
|
||||
import { commerceRoutes } from '../features/commerce/index.js';
|
||||
import { healthRoutes } from '../features/health/index.js';
|
||||
import { learningRoutes } from '../features/learning/index.js';
|
||||
import { platformAdminRoutes } from '../features/platform-admin/index.js';
|
||||
import { profileRoutes } from '../features/profile/index.js';
|
||||
import { referralRoutes } from '../features/referral/index.js';
|
||||
import { scorelineRoutes } from '../features/scoreline/index.js';
|
||||
import { tenantAdminRoutes } from '../features/tenant-admin/index.js';
|
||||
import { tenantContentRoutes } from '../features/tenant-content/index.js';
|
||||
import { tenantRoutes } from '../features/tenant/index.js';
|
||||
import { videoRoutes } from '../features/video/index.js';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
export type RouteDefinition = [method: HttpMethod, path: string, handler: Handler];
|
||||
|
||||
export function createRouter(definitions: RouteDefinition[] = allRoutes) {
|
||||
const routes = new Map<string, Handler>();
|
||||
|
||||
for (const [method, path, handler] of definitions) {
|
||||
routes.set(routeKey(method, path), handler);
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
const allRoutes: RouteDefinition[] = [
|
||||
...healthRoutes,
|
||||
...authRoutes,
|
||||
...tenantRoutes,
|
||||
...catalogRoutes,
|
||||
...learningRoutes,
|
||||
...profileRoutes,
|
||||
...scorelineRoutes,
|
||||
...videoRoutes,
|
||||
...commerceRoutes,
|
||||
...referralRoutes,
|
||||
...platformAdminRoutes,
|
||||
...tenantAdminRoutes,
|
||||
...tenantContentRoutes,
|
||||
];
|
||||
18
apps/api/src/features/auth/index.ts
Normal file
18
apps/api/src/features/auth/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
logoutRoute,
|
||||
meRoute,
|
||||
oauthProviderPlaceholderRoute,
|
||||
sendSmsCodeRoute,
|
||||
verifySmsCodeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const authRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/auth/sms/send', sendSmsCodeRoute],
|
||||
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
|
||||
['GET', '/api/auth/me', meRoute],
|
||||
['POST', '/api/auth/logout', logoutRoute],
|
||||
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
|
||||
];
|
||||
52
apps/api/src/features/auth/providers.ts
Normal file
52
apps/api/src/features/auth/providers.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
|
||||
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq';
|
||||
|
||||
export interface SmsSendInput {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
code: string;
|
||||
purpose: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SmsSendResult {
|
||||
provider: SmsProviderName;
|
||||
status: 'sent' | 'mocked';
|
||||
providerMessageId?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SmsProvider {
|
||||
name: SmsProviderName;
|
||||
send(input: SmsSendInput): Promise<SmsSendResult>;
|
||||
}
|
||||
|
||||
class MockSmsProvider implements SmsProvider {
|
||||
readonly name = 'mock' as const;
|
||||
|
||||
async send(): Promise<SmsSendResult> {
|
||||
return {
|
||||
provider: this.name,
|
||||
status: 'mocked',
|
||||
raw: { localOnly: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class NotConfiguredSmsProvider implements SmsProvider {
|
||||
constructor(readonly name: SmsProviderName) {}
|
||||
|
||||
async send(): Promise<SmsSendResult> {
|
||||
throw new Error(`${this.name} SMS provider is not configured yet`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSmsProvider(name: string): SmsProvider {
|
||||
if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun');
|
||||
if (name === 'tencent') return new NotConfiguredSmsProvider('tencent');
|
||||
return new MockSmsProvider();
|
||||
}
|
||||
|
||||
export function supportedOAuthProviders(): OAuthProviderName[] {
|
||||
return ['wechat_web', 'wechat_miniapp', 'qq'];
|
||||
}
|
||||
377
apps/api/src/features/auth/routes.ts
Normal file
377
apps/api/src/features/auth/routes.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../../core/config.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
|
||||
import { createSmsProvider } from './providers.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
bearerTokenFrom,
|
||||
clientIpFrom,
|
||||
createLoginSession,
|
||||
findUserBySessionToken,
|
||||
generateSmsCode,
|
||||
hashSmsCode,
|
||||
normalizePurpose,
|
||||
upsertPhoneUser,
|
||||
userAgentFrom,
|
||||
writeLoginEvent,
|
||||
} from './service.js';
|
||||
|
||||
interface SmsCodeRow {
|
||||
id: string;
|
||||
codeHash: string;
|
||||
attempts: number;
|
||||
expiresAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CooldownRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type SmsVerifyResult =
|
||||
| {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
verified: true;
|
||||
purpose: string;
|
||||
phone: string;
|
||||
user?: unknown;
|
||||
isNewUser?: boolean;
|
||||
session?: unknown;
|
||||
};
|
||||
|
||||
function hashEquals(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
async function activeSmsProviderName(tenantId: string) {
|
||||
if (config.authSmsProvider !== 'mock') return config.authSmsProvider;
|
||||
|
||||
const rows = await query<{ provider: string }>(
|
||||
`
|
||||
select provider
|
||||
from public.tenant_auth_providers
|
||||
where tenant_id = $1
|
||||
and provider in ('aliyun', 'tencent', 'mock')
|
||||
and status in ('active', 'testing')
|
||||
order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return rows[0]?.provider || config.authSmsProvider;
|
||||
}
|
||||
|
||||
export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const metadata = jsonObject(body.metadata);
|
||||
|
||||
const cooldownRows = await query<CooldownRow>(
|
||||
`
|
||||
select created_at as "createdAt"
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
and created_at > now() - ($4::text || ' seconds')::interval
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, phone, purpose, config.authSmsCooldownSeconds],
|
||||
);
|
||||
|
||||
const cooldownRow = cooldownRows[0];
|
||||
if (cooldownRow) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
|
||||
const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
|
||||
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
|
||||
}
|
||||
|
||||
const providerName = await activeSmsProviderName(tenantId);
|
||||
const provider = createSmsProvider(providerName);
|
||||
if (config.isProduction && provider.name === 'mock') {
|
||||
throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
|
||||
}
|
||||
|
||||
const code = generateSmsCode();
|
||||
const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata });
|
||||
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const insertResult = await client.query(
|
||||
`
|
||||
insert into public.sms_verification_codes (
|
||||
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
|
||||
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
phone,
|
||||
purpose,
|
||||
codeHash,
|
||||
providerResult.provider,
|
||||
expiresAt,
|
||||
ipAddress || null,
|
||||
userAgent || null,
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
providerStatus: providerResult.status,
|
||||
providerMessageId: providerResult.providerMessageId || null,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: `sms:${providerResult.provider}`,
|
||||
identifier: phone,
|
||||
result: 'sent',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
|
||||
return insertResult.rows[0];
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
expireIn: config.authCodeTtlSeconds,
|
||||
cooldown: config.authSmsCooldownSeconds,
|
||||
debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const code = requiredString(body, 'code');
|
||||
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
|
||||
const result = await transaction<SmsVerifyResult>(async client => {
|
||||
const codeResult = await client.query<SmsCodeRow>(
|
||||
`
|
||||
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, phone, purpose],
|
||||
);
|
||||
|
||||
const smsCode = codeResult.rows[0];
|
||||
if (!smsCode) {
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'failed',
|
||||
failureCode: 'SMS_CODE_NOT_FOUND',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'expired'
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id],
|
||||
);
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'failed',
|
||||
failureCode: 'SMS_CODE_EXPIRED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
|
||||
}
|
||||
|
||||
const matched = hashEquals(smsCode.codeHash, expectedHash);
|
||||
if (!matched) {
|
||||
const nextAttempts = smsCode.attempts + 1;
|
||||
const blocked = nextAttempts >= 5;
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set attempts = attempts + 1,
|
||||
status = case when $3::boolean then 'blocked' else status end
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id, blocked],
|
||||
);
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: blocked ? 'blocked' : 'failed',
|
||||
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, attempts: nextAttempts },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: blocked ? 429 : 400,
|
||||
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
|
||||
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
|
||||
};
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'verified', consumed_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id],
|
||||
);
|
||||
|
||||
if (purpose !== 'login') {
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, sessionIssued: false },
|
||||
});
|
||||
return { ok: true, verified: true, purpose, phone };
|
||||
}
|
||||
|
||||
const { user, isNewUser } = await upsertPhoneUser(client, { tenantId, phone });
|
||||
const session = await createLoginSession(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider: 'sms',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, isNewUser },
|
||||
});
|
||||
|
||||
return { ok: true, verified: true, purpose, phone, user, isNewUser, session };
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new HttpError(result.statusCode, result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function meRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) {
|
||||
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
|
||||
}
|
||||
|
||||
const session = await findUserBySessionToken(token);
|
||||
if (!session) {
|
||||
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
|
||||
}
|
||||
if (session.tenantId !== tenantId) {
|
||||
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: session.id,
|
||||
username: session.username,
|
||||
phone: session.phone,
|
||||
name: session.name,
|
||||
avatarUrl: session.avatarUrl,
|
||||
primaryRole: session.primaryRole,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
session: {
|
||||
id: session.sessionId,
|
||||
expiresAt: session.sessionExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) return { ok: true };
|
||||
|
||||
const session = await findUserBySessionToken(token);
|
||||
if (session && session.tenantId === tenantId) {
|
||||
await query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[session.sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
|
||||
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
|
||||
throw new HttpError(
|
||||
501,
|
||||
`${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
|
||||
'PROVIDER_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
281
apps/api/src/features/auth/service.ts
Normal file
281
apps/api/src/features/auth/service.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { config } from '../../core/config.js';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
|
||||
export interface PlatformUserSummary {
|
||||
id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
primaryRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginSessionSummary {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function clientIpFrom(ctx: RequestContext) {
|
||||
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
}
|
||||
|
||||
export function userAgentFrom(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'user-agent');
|
||||
}
|
||||
|
||||
export function normalizeChinaPhone(phone: string) {
|
||||
return phone.replace(/\s+/g, '').replace(/^\+?86/, '');
|
||||
}
|
||||
|
||||
export function assertChinaPhone(phone: string) {
|
||||
const normalized = normalizeChinaPhone(phone);
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizePurpose(value: string) {
|
||||
if (['login', 'bind_phone', 'reset_password'].includes(value)) return value;
|
||||
throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE');
|
||||
}
|
||||
|
||||
export function generateSmsCode() {
|
||||
return crypto.randomInt(100000, 1000000).toString();
|
||||
}
|
||||
|
||||
export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) {
|
||||
return crypto
|
||||
.createHmac('sha256', config.authCodePepper)
|
||||
.update([tenantId, phone, purpose, code].join(':'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function createSessionToken() {
|
||||
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<
|
||||
PlatformUserSummary & {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
sessionExpiresAt: string;
|
||||
}
|
||||
>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
);
|
||||
}
|
||||
|
||||
export function bearerTokenFrom(ctx: RequestContext) {
|
||||
const authorization = getHeader(ctx.req, 'authorization');
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<LoginSessionSummary> {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into app_private.auth_sessions (
|
||||
tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
tokenHash,
|
||||
input.provider,
|
||||
expiresAt,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function upsertPhoneUser(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
},
|
||||
) {
|
||||
const existing = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.user_identities i
|
||||
join public.platform_users u on u.id = i.user_id
|
||||
where i.provider = 'phone'
|
||||
and i.provider_subject = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.phone],
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const user = existing.rows[0];
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: false };
|
||||
}
|
||||
|
||||
const legacyPhoneUser = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.platform_users u
|
||||
where u.phone = $1
|
||||
order by u.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[input.phone],
|
||||
);
|
||||
|
||||
if (legacyPhoneUser.rows[0]) {
|
||||
const user = legacyPhoneUser.rows[0];
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (user_id, provider, provider_subject, phone)
|
||||
values ($1, 'phone', $2, $2)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
phone = excluded.phone,
|
||||
updated_at = now()
|
||||
`,
|
||||
[user.id, input.phone],
|
||||
);
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: false };
|
||||
}
|
||||
|
||||
const userResult = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
insert into public.platform_users (username, phone, primary_role, raw_profile)
|
||||
values ($1, $2, 'student', $3::jsonb)
|
||||
returning id, username, phone, name, avatar_url as "avatarUrl",
|
||||
primary_role as "primaryRole", created_at as "createdAt"
|
||||
`,
|
||||
[`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })],
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (user_id, provider, provider_subject, phone)
|
||||
values ($1, 'phone', $2, $2)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
phone = excluded.phone,
|
||||
updated_at = now()
|
||||
`,
|
||||
[user.id, input.phone],
|
||||
);
|
||||
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: true };
|
||||
}
|
||||
|
||||
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
||||
values ($1, $2, 'student', 'active')
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active', updated_at = now()
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
||||
values ($1, $2, $3::jsonb, '{}'::jsonb)
|
||||
on conflict (tenant_id, user_id) do nothing
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
totalAnswered: 0,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
studyDays: 1,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeLoginEvent(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId?: string | null;
|
||||
provider: string;
|
||||
identifier?: string;
|
||||
result: 'sent' | 'success' | 'failed' | 'blocked';
|
||||
failureCode?: string | null;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.auth_login_events (
|
||||
tenant_id, user_id, provider, identifier, result, failure_code,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId || null,
|
||||
input.provider,
|
||||
input.identifier || null,
|
||||
input.result,
|
||||
input.failureCode || null,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
202
apps/api/src/features/catalog/assets.ts
Normal file
202
apps/api/src/features/catalog/assets.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
interface CatalogAssetRow {
|
||||
id: string;
|
||||
assetType: string;
|
||||
storageProvider: string;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
title: string | null;
|
||||
fileName: string | null;
|
||||
cdnUrl: string | null;
|
||||
previewUrl: string | null;
|
||||
visibility: string;
|
||||
regionId: string | null;
|
||||
subjectId: string | null;
|
||||
}
|
||||
|
||||
function optionalUserId(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
|
||||
}
|
||||
|
||||
function hasUserContext(ctx: RequestContext) {
|
||||
return !!optionalUserId(ctx);
|
||||
}
|
||||
|
||||
function signedDownload(asset: CatalogAssetRow, expiresInSec: number) {
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
if (asset.cdnUrl) {
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: asset.cdnUrl,
|
||||
expiresAt,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1 and user_id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAssetRow) {
|
||||
const now = new Date().toISOString();
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
and (
|
||||
scope_type = 'tenant'
|
||||
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
|
||||
or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid)
|
||||
)
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now, asset.regionId, asset.subjectId],
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
|
||||
if (asset.visibility === 'private') {
|
||||
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
|
||||
}
|
||||
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const member = await hasActiveMembership(tenantId, userId);
|
||||
if (!member) {
|
||||
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
|
||||
}
|
||||
|
||||
if (asset.visibility === 'members') return { userId, svip: false };
|
||||
|
||||
const svip = await hasSvipAccess(tenantId, userId, asset);
|
||||
if (!svip) {
|
||||
throw new HttpError(403, 'SVIP entitlement is required for this asset', 'ASSET_SVIP_REQUIRED');
|
||||
}
|
||||
return { userId, svip: true };
|
||||
}
|
||||
|
||||
export async function assetsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
|
||||
const userPresent = hasUserContext(ctx);
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
|
||||
if (!includeLocked || !userPresent) {
|
||||
filters.push(`visibility in ('public', 'tenant')`);
|
||||
}
|
||||
if (assetType) {
|
||||
params.push(assetType);
|
||||
filters.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`(region_id = $${params.length} or region_id is null)`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`(subject_id = $${params.length} or subject_id is null)`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`(category_id = $${params.length} or category_id is null)`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_key as "assetKey", asset_type as "assetType",
|
||||
title, category as "categoryLabel", description,
|
||||
file_name as "fileName", preview_url as "previewUrl",
|
||||
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
category_id as "categoryId", node_id as "nodeId",
|
||||
sort_order as "order", metadata, created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.content_assets
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
|
||||
if (!assetId) {
|
||||
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
|
||||
}
|
||||
|
||||
const asset = await queryOne<CatalogAssetRow>(
|
||||
`
|
||||
select id, asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, file_name as "fileName",
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
|
||||
region_id as "regionId", subject_id as "subjectId"
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, assetId],
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
id: asset.id,
|
||||
assetType: asset.assetType,
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
previewUrl: asset.previewUrl,
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
download: signedDownload(asset, 900),
|
||||
};
|
||||
}
|
||||
47
apps/api/src/features/catalog/index.ts
Normal file
47
apps/api/src/features/catalog/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { assetDownloadRoute, assetsRoute } from './assets.js';
|
||||
import {
|
||||
announcementsRoute,
|
||||
bannersRoute,
|
||||
categoriesRoute,
|
||||
faqsRoute,
|
||||
handbookChaptersRoute,
|
||||
handbookEntriesRoute,
|
||||
handbookSubjectsRoute,
|
||||
majorsRoute,
|
||||
moduleNodesRoute,
|
||||
productsRoute,
|
||||
questionsRoute,
|
||||
regionModulesRoute,
|
||||
regionsRoute,
|
||||
schoolsRoute,
|
||||
subjectsRoute,
|
||||
svipPlansRoute,
|
||||
timelinesRoute,
|
||||
vocabularyUnitsRoute,
|
||||
vocabularyWordsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const catalogRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/catalog/regions', regionsRoute],
|
||||
['GET', '/api/catalog/region-modules', regionModulesRoute],
|
||||
['GET', '/api/catalog/module-nodes', moduleNodesRoute],
|
||||
['GET', '/api/catalog/schools', schoolsRoute],
|
||||
['GET', '/api/catalog/majors', majorsRoute],
|
||||
['GET', '/api/catalog/subjects', subjectsRoute],
|
||||
['GET', '/api/catalog/categories', categoriesRoute],
|
||||
['GET', '/api/catalog/questions', questionsRoute],
|
||||
['GET', '/api/catalog/assets', assetsRoute],
|
||||
['GET', '/api/catalog/assets/download', assetDownloadRoute],
|
||||
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
|
||||
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
|
||||
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
|
||||
['GET', '/api/catalog/handbook-chapters', handbookChaptersRoute],
|
||||
['GET', '/api/catalog/handbook-entries', handbookEntriesRoute],
|
||||
['GET', '/api/catalog/banners', bannersRoute],
|
||||
['GET', '/api/catalog/faqs', faqsRoute],
|
||||
['GET', '/api/catalog/announcements', announcementsRoute],
|
||||
['GET', '/api/catalog/products', productsRoute],
|
||||
['GET', '/api/catalog/timelines', timelinesRoute],
|
||||
['GET', '/api/catalog/svip-plans', svipPlansRoute],
|
||||
];
|
||||
580
apps/api/src/features/catalog/routes.ts
Normal file
580
apps/api/src/features/catalog/routes.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
|
||||
function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.trunc(value), max);
|
||||
}
|
||||
|
||||
export async function regionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", name, code, short_name as "shortName",
|
||||
full_name as "fullName", icon, pinyin, is_hot as "isHot",
|
||||
is_active as "isActive", sort_order as "order"
|
||||
from public.regions
|
||||
where tenant_id = $1 and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function regionModulesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId", name, type,
|
||||
icon, color, text_color as "textColor", description, route,
|
||||
is_primary_school_module as "isPrimarySchoolModule",
|
||||
is_active as "isActive", sort_order as "order"
|
||||
from public.region_modules
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function moduleNodesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const moduleId = ctx.url.searchParams.get('moduleId');
|
||||
const parentId = ctx.url.searchParams.get('parentId');
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (moduleId) {
|
||||
params.push(moduleId);
|
||||
filters.push(`module_id = $${params.length}`);
|
||||
}
|
||||
if (parentId) {
|
||||
params.push(parentId === 'root' ? null : parentId);
|
||||
filters.push(parentId === 'root' ? 'parent_id is null' : `parent_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", module_id as "moduleId",
|
||||
parent_id as "parentId", legacy_parent_id as "legacyParentId",
|
||||
region_id as "regionId", type, name, path, sort_order as "order",
|
||||
is_active as "isActive", metadata
|
||||
from public.module_nodes
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function schoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
module_id as "moduleId", name,
|
||||
professional_exam_date as "professionalExamDate",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.schools
|
||||
where ${filters.join(' and ')}
|
||||
order by name asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function majorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", name, description, study_tips as "studyTips",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.majors
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function subjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const majorId = ctx.url.searchParams.get('majorId');
|
||||
const moduleId = ctx.url.searchParams.get('moduleId');
|
||||
const type = ctx.url.searchParams.get('type');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
if (majorId) {
|
||||
params.push(majorId);
|
||||
filters.push(`major_id = $${params.length}`);
|
||||
}
|
||||
if (moduleId) {
|
||||
params.push(moduleId);
|
||||
filters.push(`module_id = $${params.length}`);
|
||||
}
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`type = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
module_id as "moduleId", school_id as "schoolId",
|
||||
major_id as "majorId", node_id as "nodeId", name, type,
|
||||
major_legacy_ids as "majorLegacyIds", icon, description, stats,
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.subjects
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function categoriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
if (nodeId) {
|
||||
params.push(nodeId);
|
||||
filters.push(`node_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", subject_id as "subjectId",
|
||||
node_id as "nodeId", name, category_type as "categoryType",
|
||||
sort_order as "order", svip_question_limit as "svipQuestionLimit",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.categories
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function questionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const categoryId = ctx.url.searchParams.get('categoryId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
const limit = intParam(ctx, 'limit', 200, 500);
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`q.subject_id = $${params.length}`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`q.category_id = $${params.length}`);
|
||||
}
|
||||
if (nodeId) {
|
||||
params.push(nodeId);
|
||||
filters.push(`q.node_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
|
||||
q.category_id as "categoryId", q.node_id as "nodeId",
|
||||
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
|
||||
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
|
||||
v.id as "versionId", v.content, v.options,
|
||||
v.correct_option_index as "correctOptionIndex",
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where ${filters.join(' and ')}
|
||||
order by q.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function vocabularyUnitsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_units
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function vocabularyWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const unitId = ctx.url.searchParams.get('unitId');
|
||||
const limit = intParam(ctx, 'limit', 1000, 2000);
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (unitId) {
|
||||
params.push(unitId);
|
||||
filters.push(`unit_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", unit_id as "unitId",
|
||||
word, phonetic, meaning, example,
|
||||
example_translation as "exampleTranslation",
|
||||
difficulty, tags, sort_order as "order",
|
||||
is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_words
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, word asc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookSubjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, type, icon, color, description, sort_order as "order",
|
||||
is_active as "isActive", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_subjects
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookChaptersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", subject_id as "subjectId",
|
||||
name, description, sort_order as "order",
|
||||
is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_chapters
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookEntriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const chapterId = ctx.url.searchParams.get('chapterId');
|
||||
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (chapterId) {
|
||||
params.push(chapterId);
|
||||
filters.push(`chapter_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", chapter_id as "chapterId",
|
||||
title, summary, ${includeContent ? 'content' : 'null::text as content'},
|
||||
tags, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_entries
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function bannersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
title, subtitle, content, button_text as "buttonText",
|
||||
button_link as "buttonLink", bg_color as "bgColor",
|
||||
border_color as "borderColor", sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.banners
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function faqsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
question, answer, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.faqs
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function announcementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", content, link,
|
||||
bg_color as "bgColor", sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.announcements
|
||||
where tenant_id = $1 and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function productsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', `status = 'active'`];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
title, price_label as "price", link, type, tags, cover,
|
||||
preview_iframe as "previewIframe", detail_images as "detailImages",
|
||||
sort_order as "order", status, created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.products
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function timelinesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", type, title, description,
|
||||
event_date as "eventDate", link, sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.timelines
|
||||
where ${filters.join(' and ')}
|
||||
order by event_date asc nulls last, sort_order asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function svipPlansRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`(region_id = $${params.length} or region_id is null)`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, price_cents as "priceCents",
|
||||
(price_cents::numeric / 100.0) as price,
|
||||
original_price_cents as "originalPriceCents",
|
||||
case when original_price_cents is null then null else (original_price_cents::numeric / 100.0) end as "originalPrice",
|
||||
days, description as "desc", per_day_label as "perDay",
|
||||
badge, recommended, coupon_only as "couponOnly",
|
||||
vp_product_id as "vpProductId", vp_enabled as "vpEnabled",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.svip_plans
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, price_cents asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
18
apps/api/src/features/commerce/index.ts
Normal file
18
apps/api/src/features/commerce/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
confirmManualPaymentRoute,
|
||||
createOrderRoute,
|
||||
entitlementCheckRoute,
|
||||
entitlementsRoute,
|
||||
ordersRoute,
|
||||
redeemActivationCodeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const commerceRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/commerce/orders', createOrderRoute],
|
||||
['GET', '/api/commerce/orders', ordersRoute],
|
||||
['GET', '/api/commerce/entitlements', entitlementsRoute],
|
||||
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
|
||||
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
|
||||
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
||||
];
|
||||
385
apps/api/src/features/commerce/routes.ts
Normal file
385
apps/api/src/features/commerce/routes.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
tenantIdFrom,
|
||||
userIdFrom,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { createOrderNo, grantSvipEntitlement } from './service.js';
|
||||
|
||||
interface PlanRow {
|
||||
id: string;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
days: number;
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
order_no: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
days: number | null;
|
||||
user_id: string | null;
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface EntitlementRow {
|
||||
id: string;
|
||||
entitlementType: string;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
startsAt: string;
|
||||
expiresAt: string | null;
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function createOrderRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const planId = requiredString(body, 'planId');
|
||||
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
|
||||
const payMethod = optionalString(body, 'payMethod') || 'manual';
|
||||
const payProvider = optionalString(body, 'payProvider') || 'manual';
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
|
||||
const plan = await queryOne<PlanRow>(
|
||||
`
|
||||
select id, name, price_cents, days, region_id
|
||||
from public.svip_plans
|
||||
where tenant_id = $1 and id = $2 and is_active = true
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, planId],
|
||||
);
|
||||
|
||||
if (!plan) {
|
||||
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
|
||||
}
|
||||
|
||||
const finalRegionId = regionId || plan.region_id;
|
||||
const amountCents = Math.max(0, plan.price_cents * quantity);
|
||||
const days = Math.max(0, plan.days * quantity);
|
||||
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const orderResult = await client.query(
|
||||
`
|
||||
insert into public.orders (
|
||||
tenant_id, user_id, order_no, status, product_type, product_name,
|
||||
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, 'pending', 'svip', $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
||||
returning id, order_no as "orderNo", status, product_type as "productType",
|
||||
product_name as "productName", amount_cents as "amountCents",
|
||||
pay_method as "payMethod", pay_provider as "payProvider",
|
||||
plan_id as "planId", days, region_id as "regionId",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
orderNo,
|
||||
`${plan.name}${quantity > 1 ? ` x${quantity}` : ''}`,
|
||||
amountCents,
|
||||
payMethod,
|
||||
payProvider,
|
||||
planId,
|
||||
days,
|
||||
finalRegionId,
|
||||
JSON.stringify({ quantity, request: body }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.order_items (tenant_id, order_id, item_type, item_id, name, quantity, unit_amount_cents, total_amount_cents, metadata)
|
||||
values ($1, $2, 'svip_plan', $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
orderResult.rows[0].id,
|
||||
planId,
|
||||
plan.name,
|
||||
quantity,
|
||||
plan.price_cents,
|
||||
amountCents,
|
||||
JSON.stringify({ days: plan.days, totalDays: days }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
|
||||
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
|
||||
`,
|
||||
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
|
||||
);
|
||||
|
||||
return orderResult.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function ordersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<EntitlementRow>(
|
||||
`
|
||||
select id, order_no as "orderNo", status, product_type as "productType",
|
||||
product_name as "productName", amount_cents as "amountCents",
|
||||
pay_method as "payMethod", pay_provider as "payProvider",
|
||||
trade_no as "tradeNo", plan_id as "planId", days,
|
||||
region_id as "regionId", paid_at as "paidAt",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.orders
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function entitlementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const items: EntitlementRow[] = await query<EntitlementRow>(
|
||||
`
|
||||
select id, entitlement_type as "entitlementType",
|
||||
scope_type as "scopeType", scope_id as "scopeId",
|
||||
source_type as "sourceType", source_id as "sourceId",
|
||||
starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata, created_at as "createdAt"
|
||||
from public.entitlements
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by expires_at desc nulls first, created_at desc
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const activeSvip = await queryOne<{
|
||||
expiresAt: string | null;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
}>(
|
||||
`
|
||||
select expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
order by expires_at desc nulls first
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now],
|
||||
);
|
||||
|
||||
const svipExpiresAt =
|
||||
items.find(item => item.entitlementType === 'svip' && item.status === 'active' && !item.expiresAt)?.expiresAt ||
|
||||
items
|
||||
.filter(item => item.entitlementType === 'svip' && item.status === 'active' && item.expiresAt)
|
||||
.map(item => item.expiresAt)
|
||||
.sort()
|
||||
.at(-1) ||
|
||||
activeSvip?.expiresAt ||
|
||||
null;
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: {
|
||||
isSvip: !!activeSvip,
|
||||
svipExpiresAt,
|
||||
svipScopeType: activeSvip?.scopeType || null,
|
||||
svipScopeId: activeSvip?.scopeId || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function entitlementCheckRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId') || '';
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const row = await queryOne<{
|
||||
id: string;
|
||||
expiresAt: string | null;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
and (
|
||||
scope_type = 'tenant'
|
||||
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
|
||||
)
|
||||
order by case when scope_type = 'region' then 0 else 1 end, expires_at desc nulls first
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now, regionId || null],
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: !!row,
|
||||
entitlement: row || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const orderNo = requiredString(body, 'orderNo');
|
||||
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
|
||||
const amountCents = optionalInteger(body, 'amountCents', -1);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const orderResult = await client.query<OrderRow>(
|
||||
`
|
||||
select id, order_no, status, amount_cents, days, user_id, region_id
|
||||
from public.orders
|
||||
where tenant_id = $1 and order_no = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, orderNo],
|
||||
);
|
||||
|
||||
const order = orderResult.rows[0];
|
||||
if (!order) {
|
||||
throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
}
|
||||
if (!order.user_id) {
|
||||
throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
|
||||
}
|
||||
if (amountCents >= 0 && amountCents !== order.amount_cents) {
|
||||
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
|
||||
}
|
||||
|
||||
if (order.status === 'paid') {
|
||||
return { orderNo, status: 'paid', idempotent: true };
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.orders
|
||||
set status = 'paid', trade_no = $3, paid_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, order.id, providerTradeNo],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.payments
|
||||
set status = 'paid', provider_trade_no = $3, paid_at = now(), updated_at = now(),
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb
|
||||
where tenant_id = $1 and order_id = $2
|
||||
`,
|
||||
[tenantId, order.id, providerTradeNo, JSON.stringify({ manualConfirm: body })],
|
||||
);
|
||||
|
||||
const entitlement = await grantSvipEntitlement(client, {
|
||||
tenantId,
|
||||
userId: order.user_id,
|
||||
days: order.days || 0,
|
||||
regionId: order.region_id,
|
||||
sourceType: 'order',
|
||||
sourceId: order.id,
|
||||
metadata: { orderNo },
|
||||
});
|
||||
|
||||
return { orderNo, status: 'paid', entitlement };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function redeemActivationCodeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const code = requiredString(body, 'code');
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const codeResult = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
days: number;
|
||||
is_used: boolean;
|
||||
used_region_id: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, code, days, is_used, used_region_id
|
||||
from public.activation_codes
|
||||
where tenant_id = $1 and lower(code::text) = lower($2)
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, code],
|
||||
);
|
||||
|
||||
const activationCode = codeResult.rows[0];
|
||||
if (!activationCode) {
|
||||
throw new HttpError(404, 'Activation code not found', 'ACTIVATION_CODE_NOT_FOUND');
|
||||
}
|
||||
if (activationCode.is_used) {
|
||||
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
|
||||
}
|
||||
|
||||
const finalRegionId = regionId || activationCode.used_region_id;
|
||||
await client.query(
|
||||
`
|
||||
update public.activation_codes
|
||||
set is_used = true, used_by = $3, used_at = now(),
|
||||
used_region_id = coalesce($4::uuid, used_region_id),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, activationCode.id, userId, finalRegionId],
|
||||
);
|
||||
|
||||
const entitlement = await grantSvipEntitlement(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
days: activationCode.days,
|
||||
regionId: finalRegionId,
|
||||
sourceType: 'activation_code',
|
||||
sourceId: activationCode.id,
|
||||
legacySourceId: activationCode.code,
|
||||
metadata: { code: activationCode.code },
|
||||
});
|
||||
|
||||
return { code: activationCode.code, entitlement };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
80
apps/api/src/features/commerce/service.ts
Normal file
80
apps/api/src/features/commerce/service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type pg from 'pg';
|
||||
|
||||
interface EntitlementInput {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
days: number;
|
||||
regionId?: string | null;
|
||||
sourceType: string;
|
||||
sourceId?: string | null;
|
||||
legacySourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
|
||||
const days = Math.max(0, Math.trunc(input.days || 0));
|
||||
const scopeType = input.regionId ? 'region' : 'tenant';
|
||||
const startsAtResult = await client.query<{ starts_at: string }>(
|
||||
`
|
||||
select greatest(
|
||||
now(),
|
||||
coalesce(max(expires_at), now())
|
||||
) as starts_at
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and scope_type = $3
|
||||
and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid)
|
||||
and status = 'active'
|
||||
`,
|
||||
[input.tenantId, input.userId, scopeType, input.regionId || null],
|
||||
);
|
||||
|
||||
const startsAt = startsAtResult.rows[0]?.starts_at;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.entitlements (
|
||||
tenant_id, user_id, entitlement_type, scope_type, scope_id,
|
||||
source_type, source_id, legacy_source_id, starts_at, expires_at, status, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'svip', $3, $4,
|
||||
$5, $6, $7, $8::timestamptz,
|
||||
$8::timestamptz + ($9::text || ' days')::interval,
|
||||
'active', $10::jsonb
|
||||
)
|
||||
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",
|
||||
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
scopeType,
|
||||
input.regionId || null,
|
||||
input.sourceType,
|
||||
input.sourceId || null,
|
||||
input.legacySourceId || null,
|
||||
startsAt,
|
||||
days,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export function createOrderNo(prefix = 'SVIP') {
|
||||
const now = new Date();
|
||||
const stamp = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('');
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `${prefix}${stamp}${random}`;
|
||||
}
|
||||
6
apps/api/src/features/health/index.ts
Normal file
6
apps/api/src/features/health/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { healthRoute } from './routes.js';
|
||||
|
||||
export const healthRoutes: RouteDefinition[] = [
|
||||
['GET', '/health', async () => healthRoute()],
|
||||
];
|
||||
11
apps/api/src/features/health/routes.ts
Normal file
11
apps/api/src/features/health/routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { queryOne } from '../../core/db.js';
|
||||
|
||||
export async function healthRoute() {
|
||||
const db = await queryOne<{ ok: number }>('select 1 as ok');
|
||||
return {
|
||||
ok: true,
|
||||
service: 'tiku-saas-api',
|
||||
db: db?.ok === 1 ? 'ok' : 'unknown',
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
28
apps/api/src/features/learning/index.ts
Normal file
28
apps/api/src/features/learning/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
createPracticeSessionRoute,
|
||||
favoriteQuestionsRoute,
|
||||
favoriteWordsRoute,
|
||||
resolveWrongQuestionRoute,
|
||||
submitAnswerRoute,
|
||||
toggleFavoriteQuestionRoute,
|
||||
toggleFavoriteWordRoute,
|
||||
updateWordProgressRoute,
|
||||
wordProgressRoute,
|
||||
wordStatsRoute,
|
||||
wrongQuestionsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const learningRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
||||
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
|
||||
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
||||
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
||||
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
||||
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
|
||||
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
|
||||
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],
|
||||
];
|
||||
453
apps/api/src/features/learning/routes.ts
Normal file
453
apps/api/src/features/learning/routes.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
optionalStringArray,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
stringParam,
|
||||
tenantIdFrom,
|
||||
userIdFrom,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
|
||||
interface QuestionAnswerRow {
|
||||
question_id: string;
|
||||
question_version_id: string | null;
|
||||
correct_option_index: number | null;
|
||||
correct_option_indices: unknown;
|
||||
answer_text: string | null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item)).filter(item => item !== '');
|
||||
}
|
||||
|
||||
function normalizeNumberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isFinite(item))
|
||||
.map(item => Math.trunc(item))
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function normalizeAnswerText(value: unknown) {
|
||||
return String(value ?? '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function arraysEqual<T>(left: T[], right: T[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
|
||||
const correctIndices = normalizeNumberArray(row.correct_option_indices);
|
||||
if (correctIndices.length) {
|
||||
return arraysEqual(
|
||||
selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b),
|
||||
correctIndices,
|
||||
);
|
||||
}
|
||||
|
||||
if (row.correct_option_index !== null && row.correct_option_index !== undefined) {
|
||||
return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index;
|
||||
}
|
||||
|
||||
if (row.answer_text) {
|
||||
return normalizeAnswerText(answerText) === normalizeAnswerText(row.answer_text);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createPracticeSessionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const mode = optionalString(body, 'mode') || 'chapter';
|
||||
const targetType = optionalString(body, 'targetType') || null;
|
||||
const targetId = optionalString(body, 'targetId') || null;
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
returning id, tenant_id as "tenantId", user_id as "userId", mode,
|
||||
target_type as "targetType", target_id as "targetId",
|
||||
started_at as "startedAt", finished_at as "finishedAt", metadata
|
||||
`,
|
||||
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const selectedOptions = optionalStringArray(body, 'selectedOptions');
|
||||
const answerText = optionalString(body, 'answerText');
|
||||
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
|
||||
|
||||
const question = await queryOne<QuestionAnswerRow>(
|
||||
`
|
||||
select q.id as question_id, q.current_version_id as question_version_id,
|
||||
v.correct_option_index, v.correct_option_indices, v.answer_text
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, questionId],
|
||||
);
|
||||
|
||||
if (!question) {
|
||||
throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const judged = judgeAnswer(question, selectedOptions, answerText);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const answerResult = await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, question_id, question_version_id, practice_session_id,
|
||||
selected_options, answer_text, is_correct
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
||||
returning id, question_id as "questionId", question_version_id as "questionVersionId",
|
||||
selected_options as "selectedOptions", answer_text as "answerText",
|
||||
is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
`,
|
||||
[tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged],
|
||||
);
|
||||
|
||||
if (judged === false) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
|
||||
values ($1, $2, $3, 1, now(), null)
|
||||
on conflict (tenant_id, user_id, question_id)
|
||||
do update set wrong_count = public.wrong_questions.wrong_count + 1,
|
||||
last_wrong_at = now(),
|
||||
resolved_at = null
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
} else if (judged === true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.wrong_questions
|
||||
set resolved_at = coalesce(resolved_at, now())
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3 and resolved_at is null
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set questions_answered_today = questions_answered_today + 1,
|
||||
stats = coalesce(stats, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
|
||||
'totalAnswered', coalesce((stats->>'totalAnswered')::integer, 0) + 1,
|
||||
'correctCount', case when $3::boolean is true then coalesce((stats->>'correctCount')::integer, 0) + 1 else null end,
|
||||
'wrongCount', case when $3::boolean is false then coalesce((stats->>'wrongCount')::integer, 0) + 1 else null end
|
||||
)),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId, judged],
|
||||
);
|
||||
|
||||
return answerResult.rows[0];
|
||||
});
|
||||
|
||||
return { item: result };
|
||||
}
|
||||
|
||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select fq.question_id as "questionId", fq.source, fq.created_at as "createdAt",
|
||||
q.subject_id as "subjectId", q.category_id as "categoryId",
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.favorite_questions fq
|
||||
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where fq.tenant_id = $1 and fq.user_id = $2
|
||||
order by fq.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const favorite = body.favorite !== false;
|
||||
|
||||
if (favorite) {
|
||||
await query(
|
||||
`
|
||||
insert into public.favorite_questions (tenant_id, user_id, question_id, source)
|
||||
values ($1, $2, $3, 'api')
|
||||
on conflict (tenant_id, user_id, question_id) do nothing
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`
|
||||
delete from public.favorite_questions
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, favorite };
|
||||
}
|
||||
|
||||
export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
|
||||
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
|
||||
q.subject_id as "subjectId", q.category_id as "categoryId",
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where wq.tenant_id = $1 and wq.user_id = $2
|
||||
and ($3::boolean = false or wq.resolved_at is null)
|
||||
order by wq.last_wrong_at desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, unresolvedOnly, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
|
||||
await query(
|
||||
`
|
||||
update public.wrong_questions
|
||||
set resolved_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function normalizeWordStatus(value: string) {
|
||||
if (['new', 'learning', 'mastered', 'reviewing'].includes(value)) return value;
|
||||
return 'learning';
|
||||
}
|
||||
|
||||
export async function wordProgressRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select p.id, p.word_id as "wordId", p.status,
|
||||
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
|
||||
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
|
||||
p.created_at as "createdAt", p.updated_at as "updatedAt",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
|
||||
from public.user_word_progress p
|
||||
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
|
||||
where p.tenant_id = $1 and p.user_id = $2
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
and ($4::text = '' or p.status = $4::text)
|
||||
order by p.updated_at desc
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, userId, unitId || null, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
|
||||
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
|
||||
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
|
||||
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const word = await client.query<{ id: string }>(
|
||||
`
|
||||
select id from public.vocabulary_words
|
||||
where tenant_id = $1 and id = $2 and is_active = true
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, wordId],
|
||||
);
|
||||
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
|
||||
|
||||
const progress = await client.query(
|
||||
`
|
||||
insert into public.user_word_progress (
|
||||
tenant_id, user_id, word_id, status, correct_count, wrong_count,
|
||||
last_review_date, next_review_date
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set status = excluded.status,
|
||||
correct_count = public.user_word_progress.correct_count + $5,
|
||||
wrong_count = public.user_word_progress.wrong_count + $6,
|
||||
last_review_date = now(),
|
||||
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
|
||||
updated_at = now()
|
||||
returning id, word_id as "wordId", status,
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set mastered_words_count = (
|
||||
select count(*) from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2 and status = 'mastered'
|
||||
),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return progress.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function favoriteWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select f.id, f.word_id as "wordId", f.note,
|
||||
coalesce(f.favorited_at, f.created_at) as "favoritedAt",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
|
||||
w.example, w.example_translation as "exampleTranslation",
|
||||
w.difficulty, w.tags
|
||||
from public.user_word_favorites f
|
||||
join public.vocabulary_words w on w.id = f.word_id and w.tenant_id = f.tenant_id
|
||||
where f.tenant_id = $1 and f.user_id = $2
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
order by coalesce(f.favorited_at, f.created_at) desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, unitId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const favorite = body.favorite !== false;
|
||||
const note = optionalString(body, 'note') || null;
|
||||
|
||||
if (favorite) {
|
||||
await query(
|
||||
`
|
||||
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
|
||||
values ($1, $2, $3, $4, now())
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set note = coalesce(excluded.note, public.user_word_favorites.note),
|
||||
favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, userId, wordId, note],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`
|
||||
delete from public.user_word_favorites
|
||||
where tenant_id = $1 and user_id = $2 and word_id = $3
|
||||
`,
|
||||
[tenantId, userId, wordId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, favorite };
|
||||
}
|
||||
|
||||
export async function wordStatsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
select
|
||||
count(w.id)::integer as "totalWords",
|
||||
count(p.word_id)::integer as "progressedWords",
|
||||
count(p.word_id) filter (where p.status = 'mastered')::integer as "masteredWords",
|
||||
count(p.word_id) filter (where p.status in ('learning', 'reviewing'))::integer as "learningWords",
|
||||
count(p.word_id) filter (where p.last_review_date::date = current_date)::integer as "todayReviewed",
|
||||
(
|
||||
select count(*)
|
||||
from public.user_word_favorites f
|
||||
join public.vocabulary_words fw on fw.id = f.word_id and fw.tenant_id = f.tenant_id
|
||||
where f.tenant_id = $1 and f.user_id = $2
|
||||
and ($3::uuid is null or fw.unit_id = $3::uuid)
|
||||
)::integer as "favoriteWords"
|
||||
from public.vocabulary_words w
|
||||
left join public.user_word_progress p
|
||||
on p.word_id = w.id and p.tenant_id = w.tenant_id and p.user_id = $2
|
||||
where w.tenant_id = $1 and w.is_active = true
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
`,
|
||||
[tenantId, userId, unitId || null],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
34
apps/api/src/features/platform-admin/index.ts
Normal file
34
apps/api/src/features/platform-admin/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
confirmInvoicePaymentRoute,
|
||||
createInvoiceRoute,
|
||||
createSubscriptionRoute,
|
||||
createTenantInvoiceFromSubscriptionRoute,
|
||||
createTenantRoute,
|
||||
platformOverviewRoute,
|
||||
platformPlansRoute,
|
||||
recordUsageRoute,
|
||||
tenantDetailRoute,
|
||||
tenantInvoicesRoute,
|
||||
tenantsRoute,
|
||||
tenantUsageRoute,
|
||||
updateTenantStatusRoute,
|
||||
upsertBillingProfileRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/overview', platformOverviewRoute],
|
||||
['GET', '/api/platform-admin/plans', platformPlansRoute],
|
||||
['GET', '/api/platform-admin/tenants', tenantsRoute],
|
||||
['POST', '/api/platform-admin/tenants', createTenantRoute],
|
||||
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
|
||||
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
|
||||
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
|
||||
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
||||
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
||||
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
|
||||
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
|
||||
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
||||
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
||||
];
|
||||
871
apps/api/src/features/platform-admin/routes.ts
Normal file
871
apps/api/src/features/platform-admin/routes.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalString,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
requirePlatformAdmin,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import {
|
||||
centsFrom,
|
||||
createInvoiceNo,
|
||||
invoiceSubtotal,
|
||||
normalizeHost,
|
||||
normalizeInvoiceItems,
|
||||
normalizeSlug,
|
||||
quantityFrom,
|
||||
recalculateInvoiceTotals,
|
||||
} from './service.js';
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
}
|
||||
|
||||
function listQuery(ctx: RequestContext, name: string) {
|
||||
return ctx.url.searchParams.get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
function toDateText(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
|
||||
queryOne<{
|
||||
totalTenants: string;
|
||||
activeTenants: string;
|
||||
suspendedTenants: string;
|
||||
trialTenants: string;
|
||||
pastDueTenants: string;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalTenants",
|
||||
count(*) filter (where status = 'active')::text as "activeTenants",
|
||||
count(*) filter (where status = 'suspended')::text as "suspendedTenants",
|
||||
count(*) filter (where billing_status = 'trial')::text as "trialTenants",
|
||||
count(*) filter (where billing_status = 'past_due')::text as "pastDueTenants"
|
||||
from public.tenants
|
||||
`,
|
||||
),
|
||||
queryOne<{
|
||||
unpaidAmountCents: string;
|
||||
paidAmountCents: string;
|
||||
overdueInvoices: string;
|
||||
}>(
|
||||
`
|
||||
select coalesce(sum(balance_cents) filter (where status in ('issued', 'overdue')), 0)::text as "unpaidAmountCents",
|
||||
coalesce(sum(paid_cents), 0)::text as "paidAmountCents",
|
||||
count(*) filter (where status = 'overdue' or (status = 'issued' and due_date < current_date))::text as "overdueInvoices"
|
||||
from public.tenant_invoices
|
||||
`,
|
||||
),
|
||||
queryOne<{ activeSubscriptions: string; expiringSoon: string }>(
|
||||
`
|
||||
select count(*) filter (where status in ('trial', 'active'))::text as "activeSubscriptions",
|
||||
count(*) filter (where status in ('trial', 'active') and expires_at <= now() + interval '30 days')::text as "expiringSoon"
|
||||
from public.tenant_subscriptions
|
||||
`,
|
||||
),
|
||||
queryOne<{ students: string; questions: string; storageGb: string }>(
|
||||
`
|
||||
select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students,
|
||||
coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions,
|
||||
coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb"
|
||||
from public.tenant_usage_records
|
||||
where period_end >= current_date - interval '31 days'
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
item: {
|
||||
tenants: {
|
||||
total: Number(tenantStats?.totalTenants || 0),
|
||||
active: Number(tenantStats?.activeTenants || 0),
|
||||
suspended: Number(tenantStats?.suspendedTenants || 0),
|
||||
trial: Number(tenantStats?.trialTenants || 0),
|
||||
pastDue: Number(tenantStats?.pastDueTenants || 0),
|
||||
},
|
||||
billing: {
|
||||
unpaidAmountCents: Number(invoiceStats?.unpaidAmountCents || 0),
|
||||
paidAmountCents: Number(invoiceStats?.paidAmountCents || 0),
|
||||
overdueInvoices: Number(invoiceStats?.overdueInvoices || 0),
|
||||
},
|
||||
subscriptions: {
|
||||
active: Number(subscriptionStats?.activeSubscriptions || 0),
|
||||
expiringSoon: Number(subscriptionStats?.expiringSoon || 0),
|
||||
},
|
||||
usage: {
|
||||
students: Number(usageStats?.students || 0),
|
||||
questions: Number(usageStats?.questions || 0),
|
||||
storageGb: Number(usageStats?.storageGb || 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function platformPlansRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
|
||||
const items = await query(
|
||||
`
|
||||
select id, code, name, description, billing_cycle as "billingCycle",
|
||||
base_amount_cents as "baseAmountCents", currency, included_quotas as "includedQuotas",
|
||||
overage_prices as "overagePrices", feature_flags as "featureFlags",
|
||||
status, sort_order as "sortOrder", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.platform_saas_plans
|
||||
where ($1::boolean = true or status = 'active')
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[includeArchived],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantsRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const status = listQuery(ctx, 'status');
|
||||
const billingStatus = listQuery(ctx, 'billingStatus');
|
||||
const q = listQuery(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
|
||||
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
|
||||
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
|
||||
b.brand_name as "brandName", b.logo_url as "logoUrl",
|
||||
s.plan_code as "planCode", s.status as "subscriptionStatus",
|
||||
s.expires_at as "subscriptionExpiresAt",
|
||||
coalesce(i.open_balance_cents, 0) as "openBalanceCents"
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join lateral (
|
||||
select plan_code, status, expires_at
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = t.id
|
||||
order by created_at desc
|
||||
limit 1
|
||||
) s on true
|
||||
left join lateral (
|
||||
select sum(balance_cents)::integer as open_balance_cents
|
||||
from public.tenant_invoices
|
||||
where tenant_id = t.id and status in ('issued', 'overdue')
|
||||
) i on true
|
||||
where ($1::text = '' or t.status = $1)
|
||||
and ($2::text = '' or t.billing_status = $2)
|
||||
and (
|
||||
$3::text = ''
|
||||
or t.slug::text ilike '%' || $3 || '%'
|
||||
or t.name ilike '%' || $3 || '%'
|
||||
or coalesce(t.legal_name, '') ilike '%' || $3 || '%'
|
||||
)
|
||||
order by t.created_at desc
|
||||
limit $4
|
||||
`,
|
||||
[status, billingStatus, q, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
|
||||
|
||||
const tenant = await queryOne(
|
||||
`
|
||||
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
|
||||
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
|
||||
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
|
||||
b.brand_name as "brandName", b.short_name as "shortName", b.logo_url as "logoUrl",
|
||||
b.service_wechat as "serviceWechat",
|
||||
bp.billing_name as "billingName", bp.tax_id as "taxId",
|
||||
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
|
||||
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
|
||||
bp.invoice_type as "invoiceType"
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
|
||||
where t.id = $1
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
if (!tenant) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
|
||||
const [domains, subscriptions, invoices, usage] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
select id, host::text, domain_type as "domainType", status, is_primary as "isPrimary",
|
||||
verified_at as "verifiedAt", created_at as "createdAt"
|
||||
from public.tenant_domains
|
||||
where tenant_id = $1
|
||||
order by is_primary desc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select id, plan_code as "planCode", status, starts_at as "startsAt",
|
||||
expires_at as "expiresAt", billing_cycle as "billingCycle",
|
||||
amount_cents as "amountCents", metadata, created_at as "createdAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 10
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
|
||||
total_cents as "totalCents", paid_cents as "paidCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 20
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select metric_key as "metricKey", metric_value as "metricValue",
|
||||
period_start as "periodStart", period_end as "periodEnd", metadata,
|
||||
created_at as "createdAt"
|
||||
from public.tenant_usage_records
|
||||
where tenant_id = $1
|
||||
order by period_end desc, metric_key asc
|
||||
limit 50
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
]);
|
||||
|
||||
return { item: { tenant, domains, subscriptions, invoices, usage } };
|
||||
}
|
||||
|
||||
export async function createTenantRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const slug = normalizeSlug(requiredString(body, 'slug'));
|
||||
const name = requiredString(body, 'name');
|
||||
if (!slug) throw new HttpError(400, 'slug is invalid', 'INVALID_SLUG');
|
||||
|
||||
const planCode = optionalString(body, 'planCode') || 'starter_yearly';
|
||||
const mode = optionalString(body, 'mode') || 'saas';
|
||||
const billingStatus = optionalString(body, 'billingStatus') || 'trial';
|
||||
const status = optionalString(body, 'status') || 'active';
|
||||
const legalName = optionalString(body, 'legalName') || null;
|
||||
const primaryHost = optionalString(body, 'primaryHost');
|
||||
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
|
||||
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const planResult = await client.query<{ code: string; billing_cycle: string; base_amount_cents: number }>(
|
||||
`
|
||||
select code, billing_cycle, base_amount_cents
|
||||
from public.platform_saas_plans
|
||||
where code = $1 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[planCode],
|
||||
);
|
||||
const plan = planResult.rows[0];
|
||||
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
|
||||
|
||||
const tenantResult = await client.query(
|
||||
`
|
||||
insert into public.tenants (slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
returning id, slug::text, name, legal_name as "legalName", status, mode,
|
||||
billing_status as "billingStatus", metadata, created_at as "createdAt"
|
||||
`,
|
||||
[slug, name, legalName, status, mode, billingStatus, JSON.stringify(metadata)],
|
||||
);
|
||||
const tenant = tenantResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
|
||||
values ($1, $2, $3, $4)
|
||||
`,
|
||||
[tenant.id, optionalString(body, 'brandName') || name, optionalString(body, 'shortName') || name, optionalString(body, 'slogan') || null],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values ($1, $2::jsonb, $3::jsonb, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
jsonBodyValue(body.featureFlags || {}),
|
||||
jsonBodyValue(body.adminFeatureFlags || {}),
|
||||
jsonBodyValue(body.publicConfig || {}),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone,
|
||||
contact_email, billing_address, invoice_title, invoice_type, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
typeof billing.billingName === 'string' ? billing.billingName : legalName || name,
|
||||
typeof billing.taxId === 'string' ? billing.taxId : null,
|
||||
typeof billing.contactName === 'string' ? billing.contactName : null,
|
||||
typeof billing.contactPhone === 'string' ? billing.contactPhone : null,
|
||||
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
|
||||
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
|
||||
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
|
||||
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
|
||||
jsonBodyValue(billing.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
if (primaryHost) {
|
||||
const host = normalizeHost(primaryHost);
|
||||
if (host) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary, verification_token)
|
||||
values ($1, $2, 'custom', 'pending', true, $3)
|
||||
`,
|
||||
[tenant.id, host, `tenant-${tenant.id.slice(0, 8)}-${Math.random().toString(36).slice(2, 10)}`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
|
||||
const expiresAt =
|
||||
optionalString(body, 'expiresAt') ||
|
||||
(plan.billing_cycle === 'yearly'
|
||||
? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
: null);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
plan.code,
|
||||
billingStatus === 'trial' ? 'trial' : 'active',
|
||||
startsAt,
|
||||
expiresAt,
|
||||
plan.billing_cycle,
|
||||
centsFrom(body.amountCents, plan.base_amount_cents),
|
||||
JSON.stringify({ source: 'platform-admin:createTenant' }),
|
||||
],
|
||||
);
|
||||
|
||||
return tenant;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const status = optionalString(body, 'status');
|
||||
const billingStatus = optionalString(body, 'billingStatus');
|
||||
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
update public.tenants
|
||||
set status = coalesce(nullif($2, ''), status),
|
||||
billing_status = coalesce(nullif($3, ''), billing_status),
|
||||
metadata = metadata || $4::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, slug::text, name, status, billing_status as "billingStatus",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
|
||||
);
|
||||
|
||||
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
|
||||
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
|
||||
on conflict (tenant_id)
|
||||
do update set billing_name = excluded.billing_name,
|
||||
tax_id = excluded.tax_id,
|
||||
contact_name = excluded.contact_name,
|
||||
contact_phone = excluded.contact_phone,
|
||||
contact_email = excluded.contact_email,
|
||||
billing_address = excluded.billing_address,
|
||||
invoice_title = excluded.invoice_title,
|
||||
invoice_type = excluded.invoice_type,
|
||||
bank_name = excluded.bank_name,
|
||||
bank_account_masked = excluded.bank_account_masked,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
|
||||
contact_name as "contactName", contact_phone as "contactPhone",
|
||||
contact_email as "contactEmail", billing_address as "billingAddress",
|
||||
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
|
||||
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
optionalString(body, 'billingName') || null,
|
||||
optionalString(body, 'taxId') || null,
|
||||
optionalString(body, 'contactName') || null,
|
||||
optionalString(body, 'contactPhone') || null,
|
||||
optionalString(body, 'contactEmail') || null,
|
||||
optionalString(body, 'billingAddress') || null,
|
||||
optionalString(body, 'invoiceTitle') || null,
|
||||
optionalString(body, 'invoiceType') || 'none',
|
||||
optionalString(body, 'bankName') || null,
|
||||
optionalString(body, 'bankAccountMasked') || null,
|
||||
jsonBodyValue(body.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const planCode = requiredString(body, 'planCode');
|
||||
|
||||
const plan = await queryOne<{ code: string; billingCycle: string; baseAmountCents: number }>(
|
||||
`
|
||||
select code, billing_cycle as "billingCycle", base_amount_cents as "baseAmountCents"
|
||||
from public.platform_saas_plans
|
||||
where code = $1 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[planCode],
|
||||
);
|
||||
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
|
||||
|
||||
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
|
||||
const expiresAt =
|
||||
optionalString(body, 'expiresAt') ||
|
||||
(plan.billingCycle === 'yearly' ? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString() : null);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
returning id, tenant_id as "tenantId", plan_code as "planCode", status,
|
||||
starts_at as "startsAt", expires_at as "expiresAt",
|
||||
billing_cycle as "billingCycle", amount_cents as "amountCents",
|
||||
metadata, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
plan.code,
|
||||
optionalString(body, 'status') || 'active',
|
||||
startsAt,
|
||||
expiresAt,
|
||||
optionalString(body, 'billingCycle') || plan.billingCycle,
|
||||
centsFrom(body.amountCents, plan.baseAmountCents),
|
||||
jsonBodyValue(body.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenants
|
||||
set billing_status = case when $2 = 'active' then 'active' else billing_status end,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[tenantId, optionalString(body, 'status') || 'active'],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantInvoicesRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const status = listQuery(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select i.id, i.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType", i.status,
|
||||
i.currency, i.total_cents as "totalCents", i.paid_cents as "paidCents",
|
||||
i.balance_cents as "balanceCents", i.billing_period_start as "billingPeriodStart",
|
||||
i.billing_period_end as "billingPeriodEnd", i.due_date as "dueDate",
|
||||
i.issued_at as "issuedAt", i.paid_at as "paidAt", i.note,
|
||||
i.created_at as "createdAt", i.updated_at as "updatedAt"
|
||||
from public.tenant_invoices i
|
||||
join public.tenants t on t.id = i.tenant_id
|
||||
where ($1::uuid is null or i.tenant_id = $1::uuid)
|
||||
and ($2::text = '' or i.status = $2)
|
||||
order by i.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId || null, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
interface CreateInvoiceInput {
|
||||
tenantId: string;
|
||||
invoiceNo?: string;
|
||||
invoiceType?: string;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
discountCents?: number;
|
||||
taxCents?: number;
|
||||
billingPeriodStart?: string | null;
|
||||
billingPeriodEnd?: string | null;
|
||||
dueDate?: string | null;
|
||||
note?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
items: ReturnType<typeof normalizeInvoiceItems>;
|
||||
}
|
||||
|
||||
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
||||
|
||||
const discountCents = centsFrom(input.discountCents, 0);
|
||||
const taxCents = centsFrom(input.taxCents, 0);
|
||||
const subtotalCents = invoiceSubtotal(input.items);
|
||||
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
||||
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
||||
|
||||
return transaction(async client => {
|
||||
const invoiceResult = await client.query(
|
||||
`
|
||||
insert into public.tenant_invoices (
|
||||
tenant_id, invoice_no, invoice_type, status, currency,
|
||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, 0, $9,
|
||||
$10::date, $11::date, $12::date,
|
||||
case when $4 = 'draft' then null else now() end,
|
||||
$13, $14::jsonb
|
||||
)
|
||||
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
||||
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoiceNo,
|
||||
input.invoiceType || 'subscription',
|
||||
input.status || 'issued',
|
||||
input.currency || 'CNY',
|
||||
subtotalCents,
|
||||
discountCents,
|
||||
taxCents,
|
||||
totalCents,
|
||||
input.billingPeriodStart || null,
|
||||
input.billingPeriodEnd || null,
|
||||
input.dueDate || null,
|
||||
input.note || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
for (const itemInput of input.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_items (
|
||||
tenant_id, invoice_id, item_type, description,
|
||||
quantity, unit_amount_cents, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoice.id,
|
||||
itemInput.itemType,
|
||||
itemInput.description,
|
||||
itemInput.quantity,
|
||||
itemInput.unitAmountCents,
|
||||
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||
JSON.stringify(itemInput.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await recalculateInvoiceTotals(client, invoice.id);
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const items = normalizeInvoiceItems(body.items);
|
||||
|
||||
const item = await createInvoiceRecord({
|
||||
tenantId,
|
||||
items,
|
||||
invoiceNo: optionalString(body, 'invoiceNo') || undefined,
|
||||
invoiceType: optionalString(body, 'invoiceType') || 'subscription',
|
||||
status: optionalString(body, 'status') || 'issued',
|
||||
currency: optionalString(body, 'currency') || 'CNY',
|
||||
discountCents: centsFrom(body.discountCents, 0),
|
||||
taxCents: centsFrom(body.taxCents, 0),
|
||||
billingPeriodStart: optionalString(body, 'billingPeriodStart') || null,
|
||||
billingPeriodEnd: optionalString(body, 'billingPeriodEnd') || null,
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? (body.metadata as Record<string, unknown>) : {},
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const invoiceId = requiredString(body, 'invoiceId');
|
||||
const amountCents = centsFrom(body.amountCents, -1);
|
||||
if (amountCents <= 0) throw new HttpError(400, 'amountCents must be positive', 'INVALID_AMOUNT');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const invoiceResult = await client.query<{ id: string; balance_cents: number }>(
|
||||
`
|
||||
select id, balance_cents
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, invoiceId],
|
||||
);
|
||||
const invoice = invoiceResult.rows[0];
|
||||
if (!invoice) throw new HttpError(404, 'Invoice not found', 'INVOICE_NOT_FOUND');
|
||||
|
||||
if (amountCents > invoice.balance_cents && invoice.balance_cents > 0) {
|
||||
throw new HttpError(409, 'Payment amount exceeds invoice balance', 'PAYMENT_AMOUNT_EXCEEDS_BALANCE');
|
||||
}
|
||||
|
||||
const paymentNo = optionalString(body, 'paymentNo') || createInvoiceNo('PAY');
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_payments (
|
||||
tenant_id, invoice_id, payment_no, provider, method, status,
|
||||
amount_cents, paid_at, provider_trade_no, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'paid', $6, coalesce($7::timestamptz, now()), $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
invoiceId,
|
||||
paymentNo,
|
||||
optionalString(body, 'provider') || 'manual',
|
||||
optionalString(body, 'method') || 'manual',
|
||||
amountCents,
|
||||
optionalString(body, 'paidAt') || null,
|
||||
optionalString(body, 'providerTradeNo') || null,
|
||||
jsonBodyValue(body.rawPayload || body),
|
||||
],
|
||||
);
|
||||
|
||||
const updatedInvoice = await recalculateInvoiceTotals(client, invoiceId);
|
||||
|
||||
if (updatedInvoice?.status === 'paid') {
|
||||
await client.query(
|
||||
`
|
||||
update public.tenants
|
||||
set billing_status = 'active', updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_subscriptions
|
||||
set status = 'active', updated_at = now()
|
||||
where tenant_id = $1
|
||||
and status in ('trial', 'past_due')
|
||||
and id = (
|
||||
select id
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 1
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
return updatedInvoice;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function recordUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const metricKey = requiredString(body, 'metricKey');
|
||||
const metricValue = quantityFrom(body.metricValue, 0);
|
||||
const periodStart = optionalString(body, 'periodStart');
|
||||
const periodEnd = optionalString(body, 'periodEnd');
|
||||
if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.tenant_usage_records (
|
||||
tenant_id, metric_key, metric_value, period_start, period_end, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::date, $5::date, $6::jsonb)
|
||||
returning id, tenant_id as "tenantId", metric_key as "metricKey",
|
||||
metric_value as "metricValue", period_start as "periodStart",
|
||||
period_end as "periodEnd", metadata, created_at as "createdAt"
|
||||
`,
|
||||
[tenantId, metricKey, metricValue, periodStart, periodEnd, jsonBodyValue(body.metadata)],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select u.id, u.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
u.metric_key as "metricKey", u.metric_value as "metricValue",
|
||||
u.period_start as "periodStart", u.period_end as "periodEnd",
|
||||
u.metadata, u.created_at as "createdAt"
|
||||
from public.tenant_usage_records u
|
||||
join public.tenants t on t.id = u.tenant_id
|
||||
where ($1::uuid is null or u.tenant_id = $1::uuid)
|
||||
order by u.period_end desc, u.created_at desc
|
||||
limit $2
|
||||
`,
|
||||
[tenantId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const subscriptionId = optionalString(body, 'subscriptionId');
|
||||
|
||||
const subscription = await queryOne<{
|
||||
id: string;
|
||||
planCode: string;
|
||||
amountCents: number;
|
||||
startsAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, plan_code as "planCode", amount_cents as "amountCents",
|
||||
starts_at as "startsAt", expires_at as "expiresAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or id = $2::uuid)
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, subscriptionId || null],
|
||||
);
|
||||
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
|
||||
|
||||
const plan = await queryOne<{ name: string }>(
|
||||
`
|
||||
select name from public.platform_saas_plans
|
||||
where code = $1
|
||||
limit 1
|
||||
`,
|
||||
[subscription.planCode],
|
||||
);
|
||||
|
||||
const item = await createInvoiceRecord({
|
||||
tenantId,
|
||||
invoiceType: 'subscription',
|
||||
status: optionalString(body, 'status') || 'issued',
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
billingPeriodStart: toDateText(subscription.startsAt),
|
||||
billingPeriodEnd: toDateText(subscription.expiresAt),
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: { source: 'subscription', subscriptionId: subscription.id },
|
||||
items: [
|
||||
{
|
||||
itemType: 'subscription',
|
||||
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
|
||||
quantity: 1,
|
||||
unitAmountCents: subscription.amountCents,
|
||||
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
118
apps/api/src/features/platform-admin/service.ts
Normal file
118
apps/api/src/features/platform-admin/service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type pg from 'pg';
|
||||
|
||||
export function createInvoiceNo(prefix = 'BILL') {
|
||||
const now = new Date();
|
||||
const stamp = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('');
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `${prefix}${stamp}${random}`;
|
||||
}
|
||||
|
||||
export function normalizeSlug(slug: string) {
|
||||
return slug
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function normalizeHost(host: string) {
|
||||
return host.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || '';
|
||||
}
|
||||
|
||||
export function centsFrom(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function quantityFrom(value: unknown, fallback = 1) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export interface InvoiceItemInput {
|
||||
itemType: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitAmountCents: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items
|
||||
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
.map(item => ({
|
||||
itemType: typeof item.itemType === 'string' && item.itemType.trim() ? item.itemType.trim() : 'service_fee',
|
||||
description: typeof item.description === 'string' && item.description.trim() ? item.description.trim() : '服务费',
|
||||
quantity: quantityFrom(item.quantity, 1),
|
||||
unitAmountCents: centsFrom(item.unitAmountCents, 0),
|
||||
metadata: item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata) ? (item.metadata as Record<string, unknown>) : {},
|
||||
}))
|
||||
.filter(item => item.unitAmountCents > 0);
|
||||
}
|
||||
|
||||
export function invoiceSubtotal(items: InvoiceItemInput[]) {
|
||||
return items.reduce((sum, item) => sum + Math.round(item.quantity * item.unitAmountCents), 0);
|
||||
}
|
||||
|
||||
export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: string) {
|
||||
const itemResult = await client.query<{ subtotal: string }>(
|
||||
`
|
||||
select coalesce(sum(amount_cents), 0)::text as subtotal
|
||||
from public.tenant_invoice_items
|
||||
where invoice_id = $1
|
||||
`,
|
||||
[invoiceId],
|
||||
);
|
||||
|
||||
const paymentResult = await client.query<{ paid: string }>(
|
||||
`
|
||||
select coalesce(sum(amount_cents), 0)::text as paid
|
||||
from public.tenant_invoice_payments
|
||||
where invoice_id = $1 and status = 'paid'
|
||||
`,
|
||||
[invoiceId],
|
||||
);
|
||||
|
||||
const subtotal = Number(itemResult.rows[0]?.subtotal || 0);
|
||||
const paid = Number(paymentResult.rows[0]?.paid || 0);
|
||||
|
||||
const updateResult = await client.query(
|
||||
`
|
||||
update public.tenant_invoices
|
||||
set subtotal_cents = $2,
|
||||
total_cents = greatest(0, $2 - discount_cents + tax_cents),
|
||||
paid_cents = $3,
|
||||
balance_cents = greatest(0, greatest(0, $2 - discount_cents + tax_cents) - $3),
|
||||
status = case
|
||||
when status = 'void' then status
|
||||
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid'
|
||||
when status = 'draft' then 'issued'
|
||||
else status
|
||||
end,
|
||||
paid_at = case
|
||||
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then coalesce(paid_at, now())
|
||||
else paid_at
|
||||
end,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, invoice_no as "invoiceNo", status, subtotal_cents as "subtotalCents",
|
||||
discount_cents as "discountCents", tax_cents as "taxCents",
|
||||
total_cents as "totalCents", paid_cents as "paidCents",
|
||||
balance_cents as "balanceCents", paid_at as "paidAt"
|
||||
`,
|
||||
[invoiceId, subtotal, paid],
|
||||
);
|
||||
|
||||
return updateResult.rows[0];
|
||||
}
|
||||
7
apps/api/src/features/profile/index.ts
Normal file
7
apps/api/src/features/profile/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
|
||||
|
||||
export const profileRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/profile/me', profileMeRoute],
|
||||
['PATCH', '/api/profile/me', updateProfileMeRoute],
|
||||
];
|
||||
259
apps/api/src/features/profile/routes.ts
Normal file
259
apps/api/src/features/profile/routes.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
primaryRole: string;
|
||||
score: number;
|
||||
regionId: string | null;
|
||||
regionName: string | null;
|
||||
selectedSchoolId: string | null;
|
||||
selectedSchoolName: string | null;
|
||||
selectedMajorId: string | null;
|
||||
selectedMajorName: string | null;
|
||||
questionsAnsweredToday: number;
|
||||
masteredWordsCount: number;
|
||||
lastCheckInDate: string | null;
|
||||
stats: JsonMap;
|
||||
progress: JsonMap;
|
||||
moduleSelections: JsonMap;
|
||||
recentActivities: unknown[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' ? value : {});
|
||||
}
|
||||
|
||||
function jsonArrayBodyValue(value: unknown) {
|
||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'recentLimit', 8, 50);
|
||||
|
||||
const profile = await queryOne<ProfileRow>(
|
||||
`
|
||||
select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name,
|
||||
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score,
|
||||
sp.region_id as "regionId", r.name as "regionName",
|
||||
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
|
||||
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
|
||||
sp.questions_answered_today as "questionsAnsweredToday",
|
||||
sp.mastered_words_count as "masteredWordsCount",
|
||||
sp.last_check_in_date as "lastCheckInDate",
|
||||
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
|
||||
sp.recent_activities as "recentActivities",
|
||||
sp.created_at as "createdAt", sp.updated_at as "updatedAt"
|
||||
from public.student_profiles sp
|
||||
join public.platform_users u on u.id = sp.user_id
|
||||
left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id
|
||||
left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id
|
||||
left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id
|
||||
where sp.tenant_id = $1 and sp.user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||
}
|
||||
|
||||
const recentPractices = await query(
|
||||
`
|
||||
select id, practice_type as "practiceType", target_legacy_id as "targetLegacyId",
|
||||
target_name as "targetName", progress, color,
|
||||
last_access_at as "lastAccessAt", last_practice_at as "lastPracticeAt",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.recent_practices
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by last_practice_at desc nulls last, last_access_at desc nulls last, updated_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
const answerStats = await queryOne<{
|
||||
totalAnswered: string;
|
||||
correctCount: string;
|
||||
wrongCount: string;
|
||||
latestAnsweredAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalAnswered",
|
||||
count(*) filter (where is_correct is true)::text as "correctCount",
|
||||
count(*) filter (where is_correct is false)::text as "wrongCount",
|
||||
max(answered_at) as "latestAnsweredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const wordStats = await queryOne<{
|
||||
totalWords: string;
|
||||
progressedWords: string;
|
||||
masteredWords: string;
|
||||
learningWords: string;
|
||||
favoriteWords: string;
|
||||
}>(
|
||||
`
|
||||
select
|
||||
(select count(*) from public.vocabulary_words where tenant_id = $1 and is_active = true)::text as "totalWords",
|
||||
count(*)::text as "progressedWords",
|
||||
count(*) filter (where status = 'mastered')::text as "masteredWords",
|
||||
count(*) filter (where status in ('learning', 'reviewing'))::text as "learningWords",
|
||||
(select count(*) from public.user_word_favorites where tenant_id = $1 and user_id = $2)::text as "favoriteWords"
|
||||
from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const entitlement = await queryOne(
|
||||
`
|
||||
select id, entitlement_type as "entitlementType", scope_type as "scopeType",
|
||||
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata
|
||||
from public.entitlements
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= now()
|
||||
and (expires_at is null or expires_at > now())
|
||||
order by expires_at desc nulls first, created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const orderSummary = await queryOne<{
|
||||
totalOrders: string;
|
||||
paidOrders: string;
|
||||
paidAmountCents: string;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalOrders",
|
||||
count(*) filter (where status = 'paid')::text as "paidOrders",
|
||||
coalesce(sum(amount_cents) filter (where status = 'paid'), 0)::text as "paidAmountCents"
|
||||
from public.orders
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
...profile,
|
||||
target: {
|
||||
regionId: profile.regionId,
|
||||
regionName: profile.regionName,
|
||||
schoolId: profile.selectedSchoolId,
|
||||
schoolName: profile.selectedSchoolName,
|
||||
majorId: profile.selectedMajorId,
|
||||
majorName: profile.selectedMajorName,
|
||||
},
|
||||
membership: {
|
||||
isSvip: !!entitlement,
|
||||
entitlement,
|
||||
},
|
||||
stats: {
|
||||
...profile.stats,
|
||||
answers: {
|
||||
totalAnswered: Number(answerStats?.totalAnswered || 0),
|
||||
correctCount: Number(answerStats?.correctCount || 0),
|
||||
wrongCount: Number(answerStats?.wrongCount || 0),
|
||||
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
|
||||
},
|
||||
vocabulary: {
|
||||
totalWords: Number(wordStats?.totalWords || 0),
|
||||
progressedWords: Number(wordStats?.progressedWords || 0),
|
||||
masteredWords: Number(wordStats?.masteredWords || 0),
|
||||
learningWords: Number(wordStats?.learningWords || 0),
|
||||
favoriteWords: Number(wordStats?.favoriteWords || 0),
|
||||
},
|
||||
orders: {
|
||||
totalOrders: Number(orderSummary?.totalOrders || 0),
|
||||
paidOrders: Number(orderSummary?.paidOrders || 0),
|
||||
paidAmountCents: Number(orderSummary?.paidAmountCents || 0),
|
||||
},
|
||||
},
|
||||
recentPractices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
|
||||
const name = optionalString(body, 'name') || null;
|
||||
const avatarUrl = optionalString(body, 'avatarUrl') || null;
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
|
||||
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
with updated_user as (
|
||||
update public.platform_users
|
||||
set name = coalesce($3, name),
|
||||
avatar_url = coalesce($4, avatar_url),
|
||||
updated_at = now()
|
||||
where id = $2
|
||||
returning id
|
||||
)
|
||||
insert into public.student_profiles (
|
||||
tenant_id, user_id, region_id, selected_school_id, selected_major_id,
|
||||
stats, progress, module_selections, recent_activities
|
||||
)
|
||||
values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
|
||||
on conflict (tenant_id, user_id)
|
||||
do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
|
||||
selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id),
|
||||
selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id),
|
||||
stats = case when $12::boolean then excluded.stats else public.student_profiles.stats end,
|
||||
progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end,
|
||||
module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end,
|
||||
recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
|
||||
updated_at = now()
|
||||
returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId",
|
||||
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId",
|
||||
stats, progress, module_selections as "moduleSelections",
|
||||
recent_activities as "recentActivities", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
name,
|
||||
avatarUrl,
|
||||
regionId,
|
||||
selectedSchoolId,
|
||||
selectedMajorId,
|
||||
jsonBodyValue(body.stats),
|
||||
jsonBodyValue(body.progress),
|
||||
jsonBodyValue(body.moduleSelections),
|
||||
jsonArrayBodyValue(body.recentActivities),
|
||||
Object.hasOwn(body, 'stats'),
|
||||
Object.hasOwn(body, 'progress'),
|
||||
Object.hasOwn(body, 'moduleSelections'),
|
||||
Object.hasOwn(body, 'recentActivities'),
|
||||
],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
34
apps/api/src/features/referral/index.ts
Normal file
34
apps/api/src/features/referral/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
crmConfigRoute,
|
||||
crmQueueRoute,
|
||||
referralBindRoute,
|
||||
referralInviteCodeRoute,
|
||||
referralManualBindRoute,
|
||||
referralQrcodeRoute,
|
||||
referralResolveRoute,
|
||||
referralSalesClientsRoute,
|
||||
referralSalesStatsRoute,
|
||||
referralStatsRoute,
|
||||
referralTeamRoute,
|
||||
referralTrackEventRoute,
|
||||
upsertCrmConfigRoute,
|
||||
upsertReferralTeamRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const referralRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/referral/invite-code', referralInviteCodeRoute],
|
||||
['POST', '/api/referral/resolve', referralResolveRoute],
|
||||
['POST', '/api/referral/track-event', referralTrackEventRoute],
|
||||
['POST', '/api/referral/bind', referralBindRoute],
|
||||
['GET', '/api/referral/stats', referralStatsRoute],
|
||||
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
|
||||
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
|
||||
['POST', '/api/referral/manual-bind', referralManualBindRoute],
|
||||
['GET', '/api/referral/team', referralTeamRoute],
|
||||
['PUT', '/api/referral/team', upsertReferralTeamRoute],
|
||||
['POST', '/api/referral/qrcode', referralQrcodeRoute],
|
||||
['GET', '/api/crm/config', crmConfigRoute],
|
||||
['PUT', '/api/crm/config', upsertCrmConfigRoute],
|
||||
['GET', '/api/crm/queue', crmQueueRoute],
|
||||
];
|
||||
920
apps/api/src/features/referral/routes.ts
Normal file
920
apps/api/src/features/referral/routes.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
|
||||
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
|
||||
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeCode(code: string) {
|
||||
return code.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function generateReferralCode() {
|
||||
let code = '';
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
code += REFERRAL_CODE_ALPHABET[randomBytes(1)[0] % REFERRAL_CODE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function centsToAmount(cents: number) {
|
||||
return Math.round(cents) / 100;
|
||||
}
|
||||
|
||||
function canViewAllReferral(auth: TenantAdminAuth) {
|
||||
return hasTenantPermission(auth, 'referral:read');
|
||||
}
|
||||
|
||||
function canViewSelfReferral(auth: TenantAdminAuth) {
|
||||
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
|
||||
}
|
||||
|
||||
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1 and user_id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return Boolean(result.rows[0]);
|
||||
}
|
||||
|
||||
async function resolveReferralCode(tenantId: string, code: string) {
|
||||
return queryOne<{
|
||||
code: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
role: string | null;
|
||||
}>(
|
||||
`
|
||||
select rc.code::text as code, rc.user_id as "userId", rc.status,
|
||||
u.username, u.name, u.phone, tm.role
|
||||
from public.referral_codes rc
|
||||
join public.platform_users u on u.id = rc.user_id
|
||||
left join lateral (
|
||||
select role
|
||||
from public.tenant_memberships
|
||||
where tenant_id = rc.tenant_id and user_id = rc.user_id and status = 'active'
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'sales' then 3
|
||||
when 'agent' then 4
|
||||
when 'teacher' then 5
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
) tm on true
|
||||
where rc.tenant_id = $1
|
||||
and rc.code = $2::citext
|
||||
and rc.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, normalizeCode(code)],
|
||||
);
|
||||
}
|
||||
|
||||
async function createOrGetReferralCode(client: pg.PoolClient, tenantId: string, userId: string, metadata: Record<string, unknown> = {}) {
|
||||
const existing = await client.query<{ code: string }>(
|
||||
`
|
||||
select code::text
|
||||
from public.referral_codes
|
||||
where tenant_id = $1 and user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
if (existing.rows[0]?.code) return existing.rows[0].code;
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const candidate = generateReferralCode();
|
||||
const inserted = await client.query<{ code: string }>(
|
||||
`
|
||||
insert into public.referral_codes (tenant_id, user_id, code, metadata)
|
||||
values ($1, $2, $3::citext, $4::jsonb)
|
||||
on conflict do nothing
|
||||
returning code::text
|
||||
`,
|
||||
[tenantId, userId, candidate, JSON.stringify(metadata)],
|
||||
);
|
||||
if (inserted.rows[0]?.code) return inserted.rows[0].code;
|
||||
}
|
||||
|
||||
throw new HttpError(500, 'Failed to generate referral code', 'REFERRAL_CODE_GENERATION_FAILED');
|
||||
}
|
||||
|
||||
async function enqueueCrmLead(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
leadId: string;
|
||||
studentUserId: string;
|
||||
source: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const cfg = await client.query<{
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
form_name: string | null;
|
||||
exam_type: string | null;
|
||||
delay_sec: number | null;
|
||||
timeout_sec: number | null;
|
||||
}>(
|
||||
`
|
||||
select enabled, url, form_name, exam_type, delay_sec, timeout_sec
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId],
|
||||
);
|
||||
const config = cfg.rows[0];
|
||||
if (!config?.enabled || !config.url) return null;
|
||||
|
||||
const student = await client.query<{
|
||||
id: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
raw_profile: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
select id, username, name, phone, email::text, raw_profile
|
||||
from public.platform_users
|
||||
where id = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.studentUserId],
|
||||
);
|
||||
|
||||
const lead = await client.query<{
|
||||
ref_code: string | null;
|
||||
referrer_user_id: string | null;
|
||||
bound_at: string;
|
||||
}>(
|
||||
`
|
||||
select ref_code::text, referrer_user_id, bound_at
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.leadId],
|
||||
);
|
||||
|
||||
const payload = {
|
||||
formName: config.form_name || '刷题题库',
|
||||
examType: config.exam_type || '成人本科',
|
||||
source: input.source,
|
||||
leadId: input.leadId,
|
||||
student: {
|
||||
id: input.studentUserId,
|
||||
username: student.rows[0]?.username || null,
|
||||
name: student.rows[0]?.name || null,
|
||||
phone: student.rows[0]?.phone || null,
|
||||
email: student.rows[0]?.email || null,
|
||||
},
|
||||
referral: {
|
||||
refCode: lead.rows[0]?.ref_code || null,
|
||||
referrerUserId: lead.rows[0]?.referrer_user_id || null,
|
||||
boundAt: lead.rows[0]?.bound_at || null,
|
||||
},
|
||||
metadata: input.metadata || {},
|
||||
};
|
||||
|
||||
const delaySeconds = Math.max(0, Number(config.delay_sec || 60));
|
||||
const idempotencyKey = `lead:${input.leadId}`;
|
||||
const queued = await client.query(
|
||||
`
|
||||
insert into public.crm_webhook_queue (
|
||||
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
|
||||
source, payload, idempotency_key, target_url
|
||||
)
|
||||
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, $4, $5, $6::jsonb, $7, $8)
|
||||
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
|
||||
do update set status = case
|
||||
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
|
||||
else 'pending'
|
||||
end,
|
||||
scheduled_at = excluded.scheduled_at,
|
||||
next_attempt_at = null,
|
||||
payload = excluded.payload,
|
||||
target_url = excluded.target_url,
|
||||
updated_at = now()
|
||||
returning id, status, scheduled_at as "scheduledAt", lead_id as "leadId"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.studentUserId,
|
||||
delaySeconds,
|
||||
input.leadId,
|
||||
input.source,
|
||||
JSON.stringify(payload),
|
||||
idempotencyKey,
|
||||
config.url,
|
||||
],
|
||||
);
|
||||
|
||||
return queued.rows[0];
|
||||
}
|
||||
|
||||
async function bindReferralLead(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
studentUserId: string;
|
||||
refCode: string;
|
||||
referrerUserId: string | null;
|
||||
source: string;
|
||||
trackId: string | null;
|
||||
bindType?: 'first_touch' | 'manual' | 'imported';
|
||||
force?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
if (input.referrerUserId && input.referrerUserId === input.studentUserId) {
|
||||
throw new HttpError(400, 'Cannot bind a lead to itself', 'SELF_REFERRAL_NOT_ALLOWED');
|
||||
}
|
||||
|
||||
const existing = await client.query<{
|
||||
id: string;
|
||||
referrerUserId: string | null;
|
||||
refCode: string | null;
|
||||
status: string;
|
||||
boundAt: string;
|
||||
}>(
|
||||
`
|
||||
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
|
||||
status, bound_at as "boundAt"
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and student_user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.studentUserId],
|
||||
);
|
||||
|
||||
if (existing.rows[0] && !input.force) {
|
||||
return { item: existing.rows[0], bound: false, protected: true };
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_leads (
|
||||
tenant_id, student_user_id, referrer_user_id, ref_code, source,
|
||||
first_track_id, bind_type, status, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::citext, $5, $6::uuid, $7, 'protected', $8::jsonb)
|
||||
on conflict (tenant_id, student_user_id)
|
||||
do update set referrer_user_id = excluded.referrer_user_id,
|
||||
ref_code = excluded.ref_code,
|
||||
source = excluded.source,
|
||||
first_track_id = coalesce(public.referral_leads.first_track_id, excluded.first_track_id),
|
||||
bind_type = excluded.bind_type,
|
||||
status = 'protected',
|
||||
metadata = public.referral_leads.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
||||
ref_code::text as "refCode", source, bind_type as "bindType", status,
|
||||
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.studentUserId,
|
||||
input.referrerUserId,
|
||||
normalizeCode(input.refCode),
|
||||
input.source,
|
||||
input.trackId,
|
||||
input.bindType || 'first_touch',
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
if (input.trackId) {
|
||||
await client.query(
|
||||
'update public.referral_tracks set lead_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[input.tenantId, input.trackId, result.rows[0].id],
|
||||
);
|
||||
}
|
||||
|
||||
return { item: result.rows[0], bound: true, protected: false };
|
||||
}
|
||||
|
||||
function crmPermission(auth: TenantAdminAuth) {
|
||||
requireTenantPermission(auth, 'crm:read');
|
||||
}
|
||||
|
||||
export async function referralInviteCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const activeMember = await userHasTenantMembership(client, tenantId, userId);
|
||||
if (!activeMember) {
|
||||
throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
|
||||
}
|
||||
const code = await createOrGetReferralCode(client, tenantId, userId, { source: 'api' });
|
||||
return { inviteCode: code };
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function referralResolveRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = optionalString(body, 'code');
|
||||
if (!code) return { valid: false };
|
||||
|
||||
const item = await resolveReferralCode(tenantId, code);
|
||||
if (!item) return { valid: false };
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
inviterId: item.userId,
|
||||
inviteCode: item.code,
|
||||
role: item.role,
|
||||
name: item.name || item.username || '推广员',
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralTrackEventRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
|
||||
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const track = await client.query(
|
||||
`
|
||||
insert into public.referral_tracks (
|
||||
tenant_id, event_type, ref_code, ref_user_id, target_user_id, source,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3::citext, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
returning id, event_type as "eventType", ref_code::text as "refCode",
|
||||
ref_user_id as "refUserId", target_user_id as "targetUserId",
|
||||
source, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
eventType,
|
||||
refCode,
|
||||
referrer?.userId || null,
|
||||
targetUserId,
|
||||
source,
|
||||
clientIpFrom(ctx),
|
||||
userAgentFrom(ctx),
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
],
|
||||
);
|
||||
|
||||
let lead = null;
|
||||
let crmQueue = null;
|
||||
if (targetUserId && referrer?.userId && (eventType === 'register' || eventType === 'enter' || eventType === 'scan')) {
|
||||
lead = await bindReferralLead(client, {
|
||||
tenantId,
|
||||
studentUserId: targetUserId,
|
||||
refCode,
|
||||
referrerUserId: referrer.userId,
|
||||
source,
|
||||
trackId: track.rows[0].id,
|
||||
metadata: { eventType },
|
||||
});
|
||||
if (lead.bound) {
|
||||
crmQueue = await enqueueCrmLead(client, {
|
||||
tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId: targetUserId,
|
||||
source,
|
||||
metadata: { trigger: 'referral.track_event', eventType },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { track: track.rows[0], lead, crmQueue };
|
||||
});
|
||||
|
||||
return { item: result.track, lead: result.lead, crmQueue: result.crmQueue };
|
||||
}
|
||||
|
||||
export async function referralBindRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = userIdFrom(ctx, body);
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const lead = await bindReferralLead(client, {
|
||||
tenantId,
|
||||
studentUserId,
|
||||
refCode,
|
||||
referrerUserId: referrer.userId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
trackId: null,
|
||||
metadata: objectValue(body.metadata),
|
||||
});
|
||||
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
|
||||
tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
metadata: { trigger: 'referral.bind' },
|
||||
}) : null;
|
||||
return { lead, crmQueue };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function referralStatsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
|
||||
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
|
||||
|
||||
const item = await queryOne<{
|
||||
referrerUserId: string;
|
||||
leadCount: string;
|
||||
paidLeadCount: string;
|
||||
paidAmountCents: string;
|
||||
trackCount: string;
|
||||
}>(
|
||||
`
|
||||
with leads as (
|
||||
select rl.student_user_id
|
||||
from public.referral_leads rl
|
||||
where rl.tenant_id = $1 and rl.referrer_user_id = $2 and rl.status = 'protected'
|
||||
),
|
||||
paid as (
|
||||
select o.user_id, sum(o.amount_cents)::bigint as amount_cents
|
||||
from public.orders o
|
||||
join leads l on l.student_user_id = o.user_id
|
||||
where o.tenant_id = $1 and o.status = 'paid'
|
||||
group by o.user_id
|
||||
)
|
||||
select $2::uuid as "referrerUserId",
|
||||
(select count(*) from leads)::text as "leadCount",
|
||||
(select count(*) from paid)::text as "paidLeadCount",
|
||||
coalesce((select sum(amount_cents) from paid), 0)::text as "paidAmountCents",
|
||||
(
|
||||
select count(*)
|
||||
from public.referral_tracks rt
|
||||
where rt.tenant_id = $1 and rt.ref_user_id = $2
|
||||
)::text as "trackCount"
|
||||
`,
|
||||
[auth.tenantId, referrerUserId],
|
||||
);
|
||||
|
||||
const paidAmountCents = Number(item?.paidAmountCents || 0);
|
||||
return {
|
||||
item: {
|
||||
referrerUserId,
|
||||
leadCount: Number(item?.leadCount || 0),
|
||||
paidLeadCount: Number(item?.paidLeadCount || 0),
|
||||
paidAmountCents,
|
||||
paidAmount: centsToAmount(paidAmountCents),
|
||||
trackCount: Number(item?.trackCount || 0),
|
||||
conversionRate: Number(item?.leadCount || 0) > 0
|
||||
? Math.round((Number(item?.paidLeadCount || 0) / Number(item?.leadCount || 0)) * 100)
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralSalesStatsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
let scopeSql = '';
|
||||
if (!canViewAllReferral(auth)) {
|
||||
params.push(auth.userId);
|
||||
scopeSql = `and tm.user_id = $${params.length}`;
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query<{
|
||||
referrerUserId: string;
|
||||
role: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
inviteCode: string | null;
|
||||
leadCount: number;
|
||||
paidLeadCount: number;
|
||||
paidAmountCents: string;
|
||||
trackCount: number;
|
||||
}>(
|
||||
`
|
||||
with referrers as (
|
||||
select tm.user_id, tm.role, u.username, u.name, u.phone,
|
||||
rc.code::text as code
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.status = 'active'
|
||||
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
|
||||
${scopeSql}
|
||||
),
|
||||
lead_stats as (
|
||||
select referrer_user_id,
|
||||
count(*)::int as lead_count,
|
||||
count(*) filter (where exists (
|
||||
select 1 from public.orders o
|
||||
where o.tenant_id = rl.tenant_id
|
||||
and o.user_id = rl.student_user_id
|
||||
and o.status = 'paid'
|
||||
))::int as paid_lead_count
|
||||
from public.referral_leads rl
|
||||
where rl.tenant_id = $1 and rl.status = 'protected'
|
||||
group by referrer_user_id
|
||||
),
|
||||
order_stats as (
|
||||
select rl.referrer_user_id, coalesce(sum(o.amount_cents), 0)::bigint as paid_amount_cents
|
||||
from public.referral_leads rl
|
||||
join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id and o.status = 'paid'
|
||||
where rl.tenant_id = $1 and rl.status = 'protected'
|
||||
group by rl.referrer_user_id
|
||||
),
|
||||
track_stats as (
|
||||
select ref_user_id, count(*)::int as track_count
|
||||
from public.referral_tracks
|
||||
where tenant_id = $1
|
||||
group by ref_user_id
|
||||
)
|
||||
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
|
||||
r.code as "inviteCode",
|
||||
coalesce(ls.lead_count, 0) as "leadCount",
|
||||
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
|
||||
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
|
||||
coalesce(ts.track_count, 0) as "trackCount"
|
||||
from referrers r
|
||||
left join lead_stats ls on ls.referrer_user_id = r.user_id
|
||||
left join order_stats os on os.referrer_user_id = r.user_id
|
||||
left join track_stats ts on ts.ref_user_id = r.user_id
|
||||
order by coalesce(ls.lead_count, 0) desc, r.name asc nulls last
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item: Record<string, unknown>) => ({
|
||||
...item,
|
||||
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
|
||||
conversionRate: Number(item.leadCount || 0) > 0
|
||||
? Math.round((Number(item.paidLeadCount || 0) / Number(item.leadCount || 0)) * 100)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralSalesClientsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
|
||||
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query<{
|
||||
id: string;
|
||||
studentUserId: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
referrerUserId: string;
|
||||
refCode: string | null;
|
||||
source: string | null;
|
||||
bindType: string;
|
||||
status: string;
|
||||
boundAt: string;
|
||||
paidAmountCents: string;
|
||||
lastPaidAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select rl.id, rl.student_user_id as "studentUserId",
|
||||
u.username, u.name, u.phone, u.email::text as email,
|
||||
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
|
||||
rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt",
|
||||
coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents",
|
||||
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
|
||||
from public.referral_leads rl
|
||||
join public.platform_users u on u.id = rl.student_user_id
|
||||
left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id
|
||||
where rl.tenant_id = $1
|
||||
and rl.referrer_user_id = $2
|
||||
and rl.status = 'protected'
|
||||
group by rl.id, u.id
|
||||
order by rl.bound_at desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, referrerUserId, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item: Record<string, unknown>) => ({
|
||||
...item,
|
||||
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralManualBindRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'referral:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = requiredString(body, 'studentUserId');
|
||||
const referrerUserId = requiredString(body, 'referrerUserId');
|
||||
|
||||
const code = await transaction(async client => {
|
||||
const member = await userHasTenantMembership(client, auth.tenantId, referrerUserId);
|
||||
if (!member) throw new HttpError(400, 'Referrer is not an active tenant member', 'REFERRER_NOT_TENANT_MEMBER');
|
||||
return createOrGetReferralCode(client, auth.tenantId, referrerUserId, { source: 'manual_bind' });
|
||||
});
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const lead = await bindReferralLead(client, {
|
||||
tenantId: auth.tenantId,
|
||||
studentUserId,
|
||||
refCode: code,
|
||||
referrerUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
trackId: null,
|
||||
bindType: 'manual',
|
||||
force: body.force === true,
|
||||
metadata: { operatorUserId: auth.userId, ...objectValue(body.metadata) },
|
||||
});
|
||||
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
|
||||
tenantId: auth.tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
metadata: { trigger: 'referral.manual_bind', operatorUserId: auth.userId },
|
||||
}) : null;
|
||||
return { lead, crmQueue };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function referralTeamRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedLeaderId = stringParam(ctx, 'leaderUserId');
|
||||
const leaderUserId = canViewAllReferral(auth) && requestedLeaderId ? requestedLeaderId : auth.userId;
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select rte.id, rte.member_user_id as "memberUserId", rte.leader_user_id as "leaderUserId",
|
||||
rte.relation_type as "relationType", rte.status, rte.metadata,
|
||||
u.username, u.name, u.phone,
|
||||
tm.role
|
||||
from public.referral_team_edges rte
|
||||
join public.platform_users u on u.id = rte.member_user_id
|
||||
left join public.tenant_memberships tm on tm.tenant_id = rte.tenant_id
|
||||
and tm.user_id = rte.member_user_id
|
||||
and tm.status = 'active'
|
||||
where rte.tenant_id = $1
|
||||
and rte.leader_user_id = $2
|
||||
and rte.status = 'active'
|
||||
order by rte.created_at asc
|
||||
`,
|
||||
[auth.tenantId, leaderUserId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertReferralTeamRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'referral:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const memberUserId = requiredString(body, 'memberUserId');
|
||||
const leaderUserId = nullableString(body.leaderUserId);
|
||||
const relationType = optionalChoice(body.relationType, ['sales_team', 'agent_network', 'teacher_class'], 'sales_team');
|
||||
const status = optionalChoice(body.status, ['active', 'disabled'], 'active');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const member = await userHasTenantMembership(client, auth.tenantId, memberUserId);
|
||||
if (!member) throw new HttpError(400, 'Member is not an active tenant member', 'MEMBER_NOT_TENANT_MEMBER');
|
||||
if (leaderUserId) {
|
||||
const leader = await userHasTenantMembership(client, auth.tenantId, leaderUserId);
|
||||
if (!leader) throw new HttpError(400, 'Leader is not an active tenant member', 'LEADER_NOT_TENANT_MEMBER');
|
||||
if (leaderUserId === memberUserId) throw new HttpError(400, 'Leader cannot be the member itself', 'SELF_LEADER_NOT_ALLOWED');
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_team_edges (
|
||||
tenant_id, member_user_id, leader_user_id, relation_type, status, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
on conflict (tenant_id, member_user_id, relation_type)
|
||||
do update set leader_user_id = excluded.leader_user_id,
|
||||
status = excluded.status,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, member_user_id as "memberUserId", leader_user_id as "leaderUserId",
|
||||
relation_type as "relationType", status, metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[auth.tenantId, memberUserId, leaderUserId, relationType, status, JSON.stringify(objectValue(body.metadata))],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function referralQrcodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const page = optionalString(body, 'page') || 'pages/index/index';
|
||||
const provider = optionalString(body, 'provider') || 'wechat-miniapp';
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const activeMember = await userHasTenantMembership(client, tenantId, userId);
|
||||
if (!activeMember) throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
|
||||
const refCode = await createOrGetReferralCode(client, tenantId, userId, { source: 'qrcode' });
|
||||
const scene = optionalString(body, 'scene') || `ref_${refCode}`;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_qrcodes (tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status, metadata)
|
||||
values ($1, $2, $3::citext, $4, $5, $6, $7, 'ready', $8::jsonb)
|
||||
on conflict (tenant_id, provider, scene, page)
|
||||
do update set user_id = excluded.user_id,
|
||||
ref_code = excluded.ref_code,
|
||||
qrcode_url = coalesce(public.referral_qrcodes.qrcode_url, excluded.qrcode_url),
|
||||
status = 'ready',
|
||||
metadata = public.referral_qrcodes.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, ref_code::text as "refCode", scene, page, provider,
|
||||
qrcode_url as "qrcodeUrl", status, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
refCode,
|
||||
scene,
|
||||
page,
|
||||
provider,
|
||||
optionalString(body, 'qrcodeUrl') || `miniapp://${page}?scene=${encodeURIComponent(scene)}`,
|
||||
JSON.stringify({ generatedBy: 'local-placeholder', ...objectValue(body.metadata) }),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function crmConfigRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const item = await queryOne(
|
||||
`
|
||||
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
if (body.secret && typeof body.secret === 'string') {
|
||||
await client.query(
|
||||
`
|
||||
insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider, last_rotated_at)
|
||||
values ($1, 'crm', 'webhook', $2, 'webhook', now())
|
||||
on conflict (tenant_id, secret_scope, secret_key)
|
||||
do update set secret_value = excluded.secret_value,
|
||||
provider = excluded.provider,
|
||||
last_rotated_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
[auth.tenantId, body.secret],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.crm_config (
|
||||
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
on conflict (tenant_id)
|
||||
do update set enabled = excluded.enabled,
|
||||
url = excluded.url,
|
||||
secret_ref = excluded.secret_ref,
|
||||
form_name = excluded.form_name,
|
||||
exam_type = excluded.exam_type,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
delay_sec = excluded.delay_sec,
|
||||
updated_at = now()
|
||||
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
body.enabled === true,
|
||||
nullableString(body.url),
|
||||
secretRef,
|
||||
optionalString(body, 'formName') || '刷题题库',
|
||||
optionalString(body, 'examType') || '成人本科',
|
||||
Number(body.timeoutSec || 10),
|
||||
Number(body.delaySec || 60),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function crmQueueRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, record_id as "recordId", status, scheduled_at as "scheduledAt",
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_webhook_queue
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
18
apps/api/src/features/scoreline/index.ts
Normal file
18
apps/api/src/features/scoreline/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
scorelineFieldsRoute,
|
||||
scorelineMajorsRoute,
|
||||
scorelineRecordsRoute,
|
||||
scorelineSchoolsRoute,
|
||||
scorelineTrendRoute,
|
||||
scorelineYearsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const scorelineRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/scoreline/fields', scorelineFieldsRoute],
|
||||
['GET', '/api/scoreline/schools', scorelineSchoolsRoute],
|
||||
['GET', '/api/scoreline/majors', scorelineMajorsRoute],
|
||||
['GET', '/api/scoreline/records', scorelineRecordsRoute],
|
||||
['GET', '/api/scoreline/trend', scorelineTrendRoute],
|
||||
['GET', '/api/scoreline/years', scorelineYearsRoute],
|
||||
];
|
||||
164
apps/api/src/features/scoreline/routes.ts
Normal file
164
apps/api/src/features/scoreline/routes.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
export async function scorelineFieldsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
field_key as "fieldKey", field_name as "fieldName",
|
||||
field_type as "fieldType", unit, is_filter as "isFilter",
|
||||
is_required as "isRequired", is_visible as "isVisible",
|
||||
is_trend as "isTrend", options, placeholder, description,
|
||||
sort_order as "sortOrder", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_fields
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc, field_name asc
|
||||
`,
|
||||
[tenantId, regionId || null],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineSchoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const q = stringParam(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 200, 1000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, short_name as "shortName", type, is_hot as "isHot",
|
||||
sort_order as "order", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_schools
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::text = '' or name ilike '%' || $3 || '%' or short_name ilike '%' || $3 || '%')
|
||||
order by is_hot desc, sort_order asc, name asc, type asc nulls last
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, regionId || null, q, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineMajorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", name, sort_order as "order",
|
||||
has_restriction as "hasRestriction", restriction_desc as "restrictionDesc",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_majors
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
order by sort_order asc, name asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, regionId || null, schoolId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineRecordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
const year = Number(ctx.url.searchParams.get('year') || 0);
|
||||
const page = intParam(ctx, 'page', 1, 10_000);
|
||||
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = `
|
||||
tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
and ($4::uuid is null or major_id = $4::uuid)
|
||||
and ($5::integer = 0 or year = $5::integer)
|
||||
`;
|
||||
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
|
||||
|
||||
const [countRow, items] = await Promise.all([
|
||||
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
|
||||
query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", major_id as "majorId", year,
|
||||
school_name as "schoolName", major_name as "majorName",
|
||||
field_values as "fieldValues", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_records
|
||||
where ${where}
|
||||
order by year desc, school_name asc nulls last, major_name asc nulls last
|
||||
limit $6 offset $7
|
||||
`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: Number(countRow?.total || 0),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scorelineTrendRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
const limit = intParam(ctx, 'limit', 20, 100);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues"
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
and ($4::uuid is null or major_id = $4::uuid)
|
||||
order by year asc, major_name asc nulls last
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, regionId || null, schoolId || null, majorId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineYearsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query<{ year: number }>(
|
||||
`
|
||||
select distinct year
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by year desc
|
||||
`,
|
||||
[tenantId, regionId || null],
|
||||
);
|
||||
|
||||
return { items: items.map(item => item.year) };
|
||||
}
|
||||
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
|
||||
const TENANT_ADMIN_ROLES = new Set([
|
||||
'tenant_owner',
|
||||
'tenant_admin',
|
||||
'tenant_operator',
|
||||
'teacher',
|
||||
'sales',
|
||||
'agent',
|
||||
]);
|
||||
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||
tenant_owner: ['*'],
|
||||
tenant_admin: ['*'],
|
||||
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
|
||||
teacher: ['content:*'],
|
||||
sales: ['codes:*', 'coupons:*', 'referral:*'],
|
||||
agent: ['codes:read', 'coupons:read', 'referral:self'],
|
||||
student: [],
|
||||
};
|
||||
|
||||
export interface TenantAdminAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function permissionKeys(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const keys = [permission];
|
||||
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
||||
keys.push(`${parts.slice(0, i).join(':')}:*`);
|
||||
}
|
||||
keys.push('*');
|
||||
return keys;
|
||||
}
|
||||
|
||||
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
|
||||
for (const key of permissionKeys(permission)) {
|
||||
const value = permissions[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
const explicit = explicitPermission(auth.permissions, permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
|
||||
return defaults.some(defaultPermission => {
|
||||
if (defaultPermission === '*') return true;
|
||||
if (defaultPermission === permission) return true;
|
||||
if (defaultPermission.endsWith(':*')) {
|
||||
return permission.startsWith(defaultPermission.slice(0, -1));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
if (!hasTenantPermission(auth, permission)) {
|
||||
throw new HttpError(403, `Tenant permission is required: ${permission}`, 'TENANT_PERMISSION_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantPermissionCatalog() {
|
||||
return {
|
||||
permissions: [
|
||||
{ key: 'tenant:overview:read', label: '租户概览' },
|
||||
{ key: 'tenant:branding:write', label: '品牌配置' },
|
||||
{ key: 'tenant:settings:write', label: '公开设置' },
|
||||
{ key: 'tenant:domains:read', label: '域名查看' },
|
||||
{ key: 'tenant:domains:write', label: '域名管理' },
|
||||
{ key: 'tenant:payment:read', label: '商户配置查看' },
|
||||
{ key: 'tenant:payment:write', label: '商户配置管理' },
|
||||
{ key: 'tenant:auth:read', label: '登录配置查看' },
|
||||
{ key: 'tenant:auth:write', label: '登录配置管理' },
|
||||
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
|
||||
{ key: 'tenant:secrets:write', label: '密钥轮换' },
|
||||
{ key: 'marketing:read', label: '活动内容查看' },
|
||||
{ key: 'marketing:write', label: '活动内容管理' },
|
||||
{ key: 'codes:read', label: '激活码查看' },
|
||||
{ key: 'codes:write', label: '激活码管理' },
|
||||
{ key: 'coupons:read', label: '优惠券查看' },
|
||||
{ key: 'coupons:write', label: '优惠券管理' },
|
||||
{ key: 'referral:read', label: '客资全局查看' },
|
||||
{ key: 'referral:self', label: '本人客资查看' },
|
||||
{ key: 'referral:write', label: '客资归属管理' },
|
||||
{ key: 'crm:read', label: 'CRM 队列查看' },
|
||||
{ key: 'crm:write', label: 'CRM 入队和重试' },
|
||||
{ key: 'members:read', label: '成员查看' },
|
||||
{ key: 'members:write', label: '成员管理' },
|
||||
{ key: 'audit:read', label: '审计日志查看' },
|
||||
{ key: 'content:*', label: '内容维护' },
|
||||
],
|
||||
roleDefaults: ROLE_PERMISSION_DEFAULTS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
select role, permissions
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and status = 'active'
|
||||
and role = any($3::text[])
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(TENANT_ADMIN_ROLES)],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
}
|
||||
64
apps/api/src/features/tenant-admin/index.ts
Normal file
64
apps/api/src/features/tenant-admin/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
activationCodesRoute,
|
||||
announcementsAdminRoute,
|
||||
auditLogsRoute,
|
||||
authProvidersRoute,
|
||||
bannersAdminRoute,
|
||||
codeBatchesRoute,
|
||||
couponsRoute,
|
||||
createTenantDomainRoute,
|
||||
disableTenantMemberRoute,
|
||||
faqsAdminRoute,
|
||||
generateActivationCodesRoute,
|
||||
paymentAccountsRoute,
|
||||
tenantDomainsRoute,
|
||||
tenantMembersRoute,
|
||||
tenantOverviewRoute,
|
||||
tenantPermissionsRoute,
|
||||
tenantSecretsRoute,
|
||||
upsertTenantMemberRoute,
|
||||
upsertActivationCodeRoute,
|
||||
upsertAnnouncementRoute,
|
||||
upsertAuthProviderRoute,
|
||||
upsertBannerRoute,
|
||||
upsertCodeBatchRoute,
|
||||
upsertCouponRoute,
|
||||
upsertFaqRoute,
|
||||
updateTenantBrandingRoute,
|
||||
updateTenantSettingsRoute,
|
||||
upsertPaymentAccountRoute,
|
||||
upsertTenantSecretRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
|
||||
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
|
||||
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
|
||||
['PUT', '/api/tenant-admin/payment-accounts', upsertPaymentAccountRoute],
|
||||
['GET', '/api/tenant-admin/auth-providers', authProvidersRoute],
|
||||
['PUT', '/api/tenant-admin/auth-providers', upsertAuthProviderRoute],
|
||||
['GET', '/api/tenant-admin/secrets', tenantSecretsRoute],
|
||||
['PUT', '/api/tenant-admin/secrets', upsertTenantSecretRoute],
|
||||
['GET', '/api/tenant-admin/banners', bannersAdminRoute],
|
||||
['PUT', '/api/tenant-admin/banners', upsertBannerRoute],
|
||||
['GET', '/api/tenant-admin/faqs', faqsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
|
||||
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
|
||||
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
|
||||
['PUT', '/api/tenant-admin/activation-codes', upsertActivationCodeRoute],
|
||||
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
|
||||
['GET', '/api/tenant-admin/coupons', couponsRoute],
|
||||
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
|
||||
['GET', '/api/tenant-admin/members', tenantMembersRoute],
|
||||
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
|
||||
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
|
||||
['GET', '/api/tenant-admin/audit-logs', auditLogsRoute],
|
||||
];
|
||||
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
354
apps/api/src/features/tenant-content/assets.ts
Normal file
354
apps/api/src/features/tenant-content/assets.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
|
||||
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
|
||||
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
|
||||
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
||||
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
assetType: string;
|
||||
storageProvider: string;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
title: string | null;
|
||||
fileName: string | null;
|
||||
cdnUrl: string | null;
|
||||
previewUrl: string | null;
|
||||
visibility: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function nullableUuid(value: unknown) {
|
||||
return nullableString(value);
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 160) || 'asset';
|
||||
}
|
||||
|
||||
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
if (asset.cdnUrl) {
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: asset.cdnUrl,
|
||||
expiresAt,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
if (!id) return;
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
|
||||
[tenantId, id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new HttpError(400, `${table} reference is not in this tenant`, code);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
|
||||
await query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const visibility = stringParam(ctx, 'visibility');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetType) {
|
||||
params.push(assetType);
|
||||
filters.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (visibility) {
|
||||
params.push(visibility);
|
||||
filters.push(`visibility = $${params.length}`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`category_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.content_assets
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
|
||||
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
|
||||
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
|
||||
const bucket = nullableString(body.bucket);
|
||||
const objectKey = nullableString(body.objectKey);
|
||||
const cdnUrl = nullableString(body.cdnUrl);
|
||||
const title = requiredString(body, 'title');
|
||||
const regionId = nullableUuid(body.regionId);
|
||||
const subjectId = nullableUuid(body.subjectId);
|
||||
const categoryId = nullableUuid(body.categoryId);
|
||||
const nodeId = nullableUuid(body.nodeId);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
|
||||
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.content_assets (
|
||||
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
||||
bucket, object_key, title, category, description, file_name, cdn_url,
|
||||
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
|
||||
is_public, region_id, subject_id, category_id, node_id, status,
|
||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18,
|
||||
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
|
||||
$25, $26::jsonb, $27::jsonb, $28, $28, $29
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
asset_key = excluded.asset_key,
|
||||
asset_type = excluded.asset_type,
|
||||
storage_provider = excluded.storage_provider,
|
||||
bucket = excluded.bucket,
|
||||
object_key = excluded.object_key,
|
||||
title = excluded.title,
|
||||
category = excluded.category,
|
||||
description = excluded.description,
|
||||
file_name = excluded.file_name,
|
||||
cdn_url = excluded.cdn_url,
|
||||
preview_url = excluded.preview_url,
|
||||
mime_type = excluded.mime_type,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
visibility = excluded.visibility,
|
||||
is_public = excluded.is_public,
|
||||
region_id = excluded.region_id,
|
||||
subject_id = excluded.subject_id,
|
||||
category_id = excluded.category_id,
|
||||
node_id = excluded.node_id,
|
||||
status = excluded.status,
|
||||
sort_order = excluded.sort_order,
|
||||
access_rules = excluded.access_rules,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
source = excluded.source,
|
||||
updated_at = now()
|
||||
where public.content_assets.tenant_id = excluded.tenant_id
|
||||
returning id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.legacyId),
|
||||
nullableString(body.assetKey),
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
title,
|
||||
nullableString(body.categoryLabel) || nullableString(body.category),
|
||||
nullableString(body.description),
|
||||
nullableString(body.fileName),
|
||||
cdnUrl,
|
||||
nullableString(body.previewUrl),
|
||||
nullableString(body.mimeType),
|
||||
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
nullableString(body.checksumSha256),
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
regionId,
|
||||
subjectId,
|
||||
categoryId,
|
||||
nodeId,
|
||||
status,
|
||||
intValue(body.order, 0),
|
||||
jsonObjectValue(body.accessRules),
|
||||
jsonObjectValue(body.metadata),
|
||||
auth.userId,
|
||||
nullableString(body.source) || 'manual',
|
||||
],
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
|
||||
title,
|
||||
assetType,
|
||||
visibility,
|
||||
status,
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const fileName = requiredString(body, 'fileName');
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
|
||||
const bucket = nullableString(body.bucket) || 'tenant-assets';
|
||||
const objectKey =
|
||||
nullableString(body.objectKey) ||
|
||||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
|
||||
return {
|
||||
upload: {
|
||||
provider: storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
method: 'PUT',
|
||||
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
headers: {
|
||||
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
|
||||
},
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
},
|
||||
assetDraft: {
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
fileName,
|
||||
mimeType: nullableString(body.mimeType),
|
||||
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
checksumSha256: nullableString(body.checksumSha256),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const assetId = requiredString(body, 'assetId');
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", asset_type as "assetType",
|
||||
storage_provider as "storageProvider", bucket, object_key as "objectKey",
|
||||
title, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", visibility, status
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: asset,
|
||||
download: placeholderSignedUrl(asset, expiresInSec),
|
||||
};
|
||||
}
|
||||
43
apps/api/src/features/tenant-content/auth.ts
Normal file
43
apps/api/src/features/tenant-content/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
|
||||
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
|
||||
|
||||
export interface TenantContentAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
select role, permissions
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and status = 'active'
|
||||
and role = any($3::text[])
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'tenant_operator' then 3
|
||||
when 'teacher' then 4
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(CONTENT_ROLES)],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
}
|
||||
951
apps/api/src/features/tenant-content/imports.ts
Normal file
951
apps/api/src/features/tenant-content/imports.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, nullableString } from './utils.js';
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface ImportIssue {
|
||||
rowNo: number;
|
||||
severity: 'error' | 'warning';
|
||||
code: string;
|
||||
fieldPath: string | null;
|
||||
message: string;
|
||||
details?: JsonObject;
|
||||
}
|
||||
|
||||
interface NormalizedQuestion {
|
||||
legacyId: string | null;
|
||||
type: string;
|
||||
typeLabel: string | null;
|
||||
content: string;
|
||||
options: unknown[];
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: number[];
|
||||
answerText: string | null;
|
||||
explanation: string | null;
|
||||
difficulty: number;
|
||||
tags: string[];
|
||||
mediaUrl: string | null;
|
||||
subQuestions: unknown[];
|
||||
codeLang: string | null;
|
||||
codeTemplate: string | null;
|
||||
sourceHash: string;
|
||||
}
|
||||
|
||||
interface PreviewResult {
|
||||
job: {
|
||||
id: string;
|
||||
status: string;
|
||||
totalCount: number;
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
items: Array<{
|
||||
rowNo: number;
|
||||
status: 'valid' | 'invalid';
|
||||
externalId: string | null;
|
||||
normalized: NormalizedQuestion | null;
|
||||
issues: ImportIssue[];
|
||||
}>;
|
||||
issues: ImportIssue[];
|
||||
}
|
||||
|
||||
const OBJECTIVE_TYPES = new Set(['choice', 'multi', 'judge', 'image']);
|
||||
const READING_TYPE = 'reading';
|
||||
const KNOWN_TYPES = new Set([
|
||||
...OBJECTIVE_TYPES,
|
||||
READING_TYPE,
|
||||
'text',
|
||||
'terms',
|
||||
'short_answer',
|
||||
'composition',
|
||||
'discuss',
|
||||
'translation',
|
||||
'case_analysis',
|
||||
'brief_analysis',
|
||||
'calculation',
|
||||
'analysis_design',
|
||||
'combination',
|
||||
'solution',
|
||||
]);
|
||||
|
||||
function objectValue(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function stringArrayValue(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function numberArrayValue(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(item => Number(item))
|
||||
.filter(Number.isFinite)
|
||||
.map(item => Math.trunc(item));
|
||||
}
|
||||
|
||||
function parseQuestionItems(body: JsonObject) {
|
||||
const source = body.items ?? body.questions ?? body.payload;
|
||||
let parsed: unknown = source;
|
||||
if (typeof source === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new HttpError(400, 'payload must be a valid JSON array string', 'INVALID_IMPORT_PAYLOAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new HttpError(400, 'items/questions/payload must be a JSON array', 'INVALID_IMPORT_PAYLOAD');
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
throw new HttpError(400, 'Import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD');
|
||||
}
|
||||
if (parsed.length > 2000) {
|
||||
throw new HttpError(400, 'A single import job can contain at most 2000 items', 'IMPORT_TOO_LARGE');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function contentHash(value: unknown) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: unknown, issues: ImportIssue[], rowNo: number) {
|
||||
if (value === undefined || value === null || value === '') return 1;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'INVALID_DIFFICULTY',
|
||||
fieldPath: 'difficulty',
|
||||
message: 'difficulty must be a number from 1 to 5',
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
const difficulty = Math.trunc(parsed);
|
||||
if (difficulty < 1 || difficulty > 5) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'DIFFICULTY_OUT_OF_RANGE',
|
||||
fieldPath: 'difficulty',
|
||||
message: 'difficulty is outside the recommended 1-5 range and was clamped',
|
||||
details: { original: value },
|
||||
});
|
||||
}
|
||||
return Math.min(5, Math.max(1, difficulty));
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath = 'options') {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized = value.filter(item => {
|
||||
if (typeof item === 'string') return item.trim() !== '';
|
||||
return item && typeof item === 'object';
|
||||
});
|
||||
|
||||
if (normalized.length !== value.length) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'EMPTY_OPTIONS_REMOVED',
|
||||
fieldPath,
|
||||
message: 'empty options were removed during normalization',
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSubQuestions(value: unknown, issues: ImportIssue[], rowNo: number) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.map((raw, index) => {
|
||||
const sub = objectValue(raw);
|
||||
const fieldPrefix = `sub_questions[${index}]`;
|
||||
const type = stringValue(sub.type) || 'choice';
|
||||
const content = stringValue(sub.content);
|
||||
const options = normalizeOptions(sub.options, issues, rowNo, `${fieldPrefix}.options`);
|
||||
const correctOptionIndices = numberArrayValue(sub.correctOptionIndices ?? sub.correct_option_indices);
|
||||
const answerText = stringValue(sub.answerText ?? sub.answer_text);
|
||||
|
||||
if (!content) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTION_CONTENT_REQUIRED',
|
||||
fieldPath: `${fieldPrefix}.content`,
|
||||
message: 'sub question content is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (OBJECTIVE_TYPES.has(type)) {
|
||||
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo, fieldPrefix);
|
||||
} else if (!answerText) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTION_ANSWER_REQUIRED',
|
||||
fieldPath: `${fieldPrefix}.answerText`,
|
||||
message: 'subjective sub question requires answerText',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
typeLabel: stringValue(sub.typeLabel ?? sub.type_label) || null,
|
||||
content,
|
||||
options,
|
||||
correctOptionIndices,
|
||||
answerText: answerText || null,
|
||||
explanation: stringValue(sub.explanation) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateObjectiveAnswer(
|
||||
options: unknown[],
|
||||
correctOptionIndices: number[],
|
||||
issues: ImportIssue[],
|
||||
rowNo: number,
|
||||
fieldPrefix = '',
|
||||
) {
|
||||
const prefix = fieldPrefix ? `${fieldPrefix}.` : '';
|
||||
if (options.length < 2) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'OPTIONS_REQUIRED',
|
||||
fieldPath: `${prefix}options`,
|
||||
message: 'objective question requires at least two options',
|
||||
});
|
||||
}
|
||||
|
||||
if (correctOptionIndices.length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CORRECT_OPTION_REQUIRED',
|
||||
fieldPath: `${prefix}correctOptionIndices`,
|
||||
message: 'objective question requires correctOptionIndices',
|
||||
});
|
||||
}
|
||||
|
||||
for (const index of correctOptionIndices) {
|
||||
if (index < 0 || index >= options.length) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CORRECT_OPTION_OUT_OF_RANGE',
|
||||
fieldPath: `${prefix}correctOptionIndices`,
|
||||
message: 'correct option index is outside the options range',
|
||||
details: { index, optionsCount: options.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuestion(raw: unknown, rowNo: number) {
|
||||
const issues: ImportIssue[] = [];
|
||||
const source = objectValue(raw);
|
||||
if (!source || Object.keys(source).length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'ROW_OBJECT_REQUIRED',
|
||||
fieldPath: null,
|
||||
message: 'each import row must be an object',
|
||||
});
|
||||
return { normalized: null, issues };
|
||||
}
|
||||
|
||||
const subQuestions = normalizeSubQuestions(source.sub_questions ?? source.subQuestions, issues, rowNo);
|
||||
let type = stringValue(source.type) || (subQuestions.length ? READING_TYPE : 'choice');
|
||||
if (subQuestions.length) type = READING_TYPE;
|
||||
|
||||
if (!KNOWN_TYPES.has(type)) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'UNKNOWN_QUESTION_TYPE',
|
||||
fieldPath: 'type',
|
||||
message: 'unknown question type was kept for compatibility',
|
||||
details: { type },
|
||||
});
|
||||
}
|
||||
|
||||
const content = stringValue(source.content);
|
||||
if (!content) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CONTENT_REQUIRED',
|
||||
fieldPath: 'content',
|
||||
message: 'question content is required',
|
||||
});
|
||||
}
|
||||
|
||||
const options = normalizeOptions(source.options, issues, rowNo);
|
||||
const legacyCorrectIndex = source.correctOptionIndex ?? source.correct_option_index;
|
||||
const correctOptionIndices = numberArrayValue(source.correctOptionIndices ?? source.correct_option_indices);
|
||||
if (correctOptionIndices.length === 0 && legacyCorrectIndex !== undefined && legacyCorrectIndex !== null && legacyCorrectIndex !== '') {
|
||||
const parsed = Number(legacyCorrectIndex);
|
||||
if (Number.isFinite(parsed)) correctOptionIndices.push(Math.trunc(parsed));
|
||||
}
|
||||
const answerText = stringValue(source.answerText ?? source.answer_text);
|
||||
|
||||
if (OBJECTIVE_TYPES.has(type)) {
|
||||
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo);
|
||||
} else if (type === READING_TYPE) {
|
||||
if (subQuestions.length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTIONS_REQUIRED',
|
||||
fieldPath: 'sub_questions',
|
||||
message: 'reading question requires sub_questions',
|
||||
});
|
||||
}
|
||||
} else if (!answerText) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'ANSWER_TEXT_REQUIRED',
|
||||
fieldPath: 'answerText',
|
||||
message: 'subjective question requires answerText',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedWithoutHash = {
|
||||
legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null,
|
||||
type,
|
||||
typeLabel: stringValue(source.typeLabel ?? source.type_label) || null,
|
||||
content,
|
||||
options,
|
||||
correctOptionIndex: correctOptionIndices.length === 1 ? correctOptionIndices[0] : null,
|
||||
correctOptionIndices,
|
||||
answerText: answerText || null,
|
||||
explanation: stringValue(source.explanation) || null,
|
||||
difficulty: normalizeDifficulty(source.difficulty, issues, rowNo),
|
||||
tags: stringArrayValue(source.tags),
|
||||
mediaUrl: stringValue(source.mediaUrl ?? source.media_url) || null,
|
||||
subQuestions,
|
||||
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
|
||||
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
|
||||
};
|
||||
|
||||
const normalized: NormalizedQuestion = {
|
||||
...normalizedWithoutHash,
|
||||
sourceHash: contentHash(normalizedWithoutHash),
|
||||
};
|
||||
|
||||
return { normalized, issues };
|
||||
}
|
||||
|
||||
async function assertTargetReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
|
||||
const subjectId = requiredString(body, 'subjectId');
|
||||
const categoryId = requiredString(body, 'categoryId');
|
||||
const nodeId = nullableString(body.nodeId);
|
||||
const questionBankId = nullableString(body.questionBankId);
|
||||
const regionId = nullableString(body.regionId);
|
||||
|
||||
const subject = await client.query(
|
||||
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, subjectId],
|
||||
);
|
||||
if (!subject.rows[0]) {
|
||||
throw new HttpError(400, 'subjectId is not in this tenant', 'SUBJECT_NOT_FOUND');
|
||||
}
|
||||
|
||||
const category = await client.query(
|
||||
'select id from public.categories where tenant_id = $1 and id = $2 and subject_id = $3 limit 1',
|
||||
[auth.tenantId, categoryId, subjectId],
|
||||
);
|
||||
if (!category.rows[0]) {
|
||||
throw new HttpError(400, 'categoryId is not in this tenant or subject', 'CATEGORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (nodeId) {
|
||||
const node = await client.query(
|
||||
'select id from public.module_nodes where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, nodeId],
|
||||
);
|
||||
if (!node.rows[0]) {
|
||||
throw new HttpError(400, 'nodeId is not in this tenant', 'NODE_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
if (questionBankId) {
|
||||
const questionBank = await client.query(
|
||||
'select id from public.question_banks where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, questionBankId],
|
||||
);
|
||||
if (!questionBank.rows[0]) {
|
||||
throw new HttpError(400, 'questionBankId is not in this tenant', 'QUESTION_BANK_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
if (regionId) {
|
||||
const region = await client.query(
|
||||
'select id from public.regions where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, regionId],
|
||||
);
|
||||
if (!region.rows[0]) {
|
||||
throw new HttpError(400, 'regionId is not in this tenant', 'REGION_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
return { subjectId, categoryId, nodeId, questionBankId, regionId };
|
||||
}
|
||||
|
||||
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
|
||||
const rawItems = parseQuestionItems(body);
|
||||
const sourceFormat = stringValue(body.sourceFormat) || 'json';
|
||||
const sourceName = stringValue(body.sourceName) || null;
|
||||
|
||||
if (sourceFormat !== 'json') {
|
||||
throw new HttpError(400, 'Only json sourceFormat is supported by the synchronous API for now', 'IMPORT_FORMAT_NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
return transaction(async client => {
|
||||
const target = await assertTargetReferences(client, auth, body);
|
||||
const normalizedItems = rawItems.map((raw, index) => {
|
||||
const rowNo = index + 1;
|
||||
const { normalized, issues } = normalizeQuestion(raw, rowNo);
|
||||
return {
|
||||
rowNo,
|
||||
status: issues.some(issue => issue.severity === 'error') ? 'invalid' as const : 'valid' as const,
|
||||
externalId: normalized?.legacyId || null,
|
||||
source: raw,
|
||||
normalized,
|
||||
issues,
|
||||
};
|
||||
});
|
||||
|
||||
const issues = normalizedItems.flatMap(item => item.issues);
|
||||
const errorCount = issues.filter(issue => issue.severity === 'error').length;
|
||||
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
|
||||
const validCount = normalizedItems.filter(item => item.status === 'valid').length;
|
||||
const rawPayload = JSON.stringify(rawItems);
|
||||
const normalizedPayload = JSON.stringify(normalizedItems.map(item => item.normalized).filter(Boolean));
|
||||
|
||||
const jobResult = await client.query(
|
||||
`
|
||||
insert into public.content_import_jobs (
|
||||
tenant_id, created_by, import_type, source_format, status,
|
||||
source_name, source_hash, target_region_id, target_subject_id,
|
||||
target_category_id, target_node_id, target_question_bank_id,
|
||||
dry_run, total_count, valid_count, error_count, warning_count,
|
||||
summary, raw_payload, normalized_payload
|
||||
)
|
||||
values (
|
||||
$1, $2, 'questions', $3, 'preview',
|
||||
$4, $5, $6::uuid, $7::uuid,
|
||||
$8::uuid, $9::uuid, $10::uuid,
|
||||
true, $11, $12, $13, $14,
|
||||
$15::jsonb, $16::jsonb, $17::jsonb
|
||||
)
|
||||
returning id, status, total_count as "totalCount", valid_count as "validCount",
|
||||
error_count as "errorCount", warning_count as "warningCount"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
sourceFormat,
|
||||
sourceName,
|
||||
contentHash(rawItems),
|
||||
target.regionId,
|
||||
target.subjectId,
|
||||
target.categoryId,
|
||||
target.nodeId,
|
||||
target.questionBankId,
|
||||
rawItems.length,
|
||||
validCount,
|
||||
errorCount,
|
||||
warningCount,
|
||||
JSON.stringify({ target, generatedAt: new Date().toISOString() }),
|
||||
rawPayload,
|
||||
normalizedPayload,
|
||||
],
|
||||
);
|
||||
|
||||
const job = jobResult.rows[0];
|
||||
const responseItems: PreviewResult['items'] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemResult = await client.query(
|
||||
`
|
||||
insert into public.content_import_items (
|
||||
tenant_id, job_id, row_no, external_id, status, target_type,
|
||||
source_payload, normalized_payload, content_hash, issues_count
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'question', $6::jsonb, $7::jsonb, $8, $9)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
item.rowNo,
|
||||
item.externalId,
|
||||
item.status,
|
||||
JSON.stringify(item.source),
|
||||
JSON.stringify(item.normalized || {}),
|
||||
item.normalized?.sourceHash || contentHash(item.source),
|
||||
item.issues.length,
|
||||
],
|
||||
);
|
||||
|
||||
const itemId = itemResult.rows[0].id;
|
||||
for (const issue of item.issues) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.content_import_issues (
|
||||
tenant_id, job_id, item_id, row_no, severity, code,
|
||||
field_path, message, details
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
itemId,
|
||||
issue.rowNo,
|
||||
issue.severity,
|
||||
issue.code,
|
||||
issue.fieldPath,
|
||||
issue.message,
|
||||
JSON.stringify(issue.details || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
responseItems.push({
|
||||
rowNo: item.rowNo,
|
||||
status: item.status,
|
||||
externalId: item.externalId,
|
||||
normalized: item.normalized,
|
||||
issues: item.issues,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.import.previewed', 'content_import_job', $3, $4::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, job.id, JSON.stringify({ importType: 'questions', total: rawItems.length, errorCount, warningCount })],
|
||||
);
|
||||
|
||||
return {
|
||||
job,
|
||||
items: responseItems,
|
||||
issues,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
total_count: number;
|
||||
valid_count: number;
|
||||
error_count: number;
|
||||
warning_count: number;
|
||||
target_region_id: string | null;
|
||||
target_subject_id: string;
|
||||
target_category_id: string;
|
||||
target_node_id: string | null;
|
||||
target_question_bank_id: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_subject_id, target_category_id,
|
||||
target_node_id, target_question_bank_id
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = 'questions'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
|
||||
const job = result.rows[0];
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async function currentVersionHash(client: pg.PoolClient, questionId: string) {
|
||||
const result = await client.query<{ source_hash: string | null }>(
|
||||
`
|
||||
select v.source_hash
|
||||
from public.questions q
|
||||
join public.question_versions v on v.id = q.current_version_id
|
||||
where q.id = $1
|
||||
limit 1
|
||||
`,
|
||||
[questionId],
|
||||
);
|
||||
return result.rows[0]?.source_hash || null;
|
||||
}
|
||||
|
||||
async function importOneQuestion(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantContentAuth,
|
||||
job: {
|
||||
id: string;
|
||||
target_subject_id: string;
|
||||
target_category_id: string;
|
||||
target_node_id: string | null;
|
||||
target_question_bank_id: string | null;
|
||||
},
|
||||
item: {
|
||||
id: string;
|
||||
row_no: number;
|
||||
normalized_payload: NormalizedQuestion;
|
||||
},
|
||||
) {
|
||||
const normalized = item.normalized_payload;
|
||||
const legacyId = normalized.legacyId || `content-import:${job.id}:${item.row_no}`;
|
||||
const existing = await client.query<{ id: string }>(
|
||||
'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1 for update',
|
||||
[auth.tenantId, legacyId],
|
||||
);
|
||||
const existingQuestion = existing.rows[0];
|
||||
|
||||
let questionId = existingQuestion?.id || '';
|
||||
if (!existingQuestion) {
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.questions (
|
||||
tenant_id, question_bank_id, subject_id, category_id, node_id,
|
||||
legacy_id, type, type_label, difficulty, tags, media_url, status
|
||||
)
|
||||
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.target_question_bank_id,
|
||||
job.target_subject_id,
|
||||
job.target_category_id,
|
||||
job.target_node_id,
|
||||
legacyId,
|
||||
normalized.type,
|
||||
normalized.typeLabel,
|
||||
normalized.difficulty,
|
||||
JSON.stringify(normalized.tags),
|
||||
normalized.mediaUrl,
|
||||
],
|
||||
);
|
||||
questionId = inserted.rows[0].id;
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = $3::uuid,
|
||||
subject_id = $4::uuid,
|
||||
category_id = $5::uuid,
|
||||
node_id = $6::uuid,
|
||||
type = $7,
|
||||
type_label = $8,
|
||||
difficulty = $9,
|
||||
tags = $10::jsonb,
|
||||
media_url = $11,
|
||||
status = 'published',
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
job.target_question_bank_id,
|
||||
job.target_subject_id,
|
||||
job.target_category_id,
|
||||
job.target_node_id,
|
||||
normalized.type,
|
||||
normalized.typeLabel,
|
||||
normalized.difficulty,
|
||||
JSON.stringify(normalized.tags),
|
||||
normalized.mediaUrl,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
|
||||
if (previousHash && previousHash === normalized.sourceHash) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_items
|
||||
set status = 'skipped', target_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, item.id, questionId],
|
||||
);
|
||||
return 'skipped' as const;
|
||||
}
|
||||
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[questionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
|
||||
const version = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nextVersionNo,
|
||||
normalized.content,
|
||||
JSON.stringify(normalized.options),
|
||||
normalized.correctOptionIndex,
|
||||
JSON.stringify(normalized.correctOptionIndices),
|
||||
normalized.answerText,
|
||||
normalized.explanation,
|
||||
JSON.stringify(normalized.subQuestions),
|
||||
normalized.codeLang,
|
||||
normalized.codeTemplate,
|
||||
normalized.sourceHash,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, questionId, version.rows[0].id],
|
||||
);
|
||||
|
||||
const status = existingQuestion ? 'updated' : 'inserted';
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_items
|
||||
set status = $3, target_id = $4, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, item.id, status, questionId],
|
||||
);
|
||||
|
||||
return status as 'inserted' | 'updated';
|
||||
}
|
||||
|
||||
export async function previewQuestionsImportRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
return createQuestionPreviewJob(auth, body);
|
||||
}
|
||||
|
||||
export async function importQuestionsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const allowPartial = boolValue(body.allowPartial, false);
|
||||
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
|
||||
|
||||
const createdPreview = jobId ? null : await createQuestionPreviewJob(auth, body);
|
||||
const finalJobId = jobId || createdPreview?.job.id || '';
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const job = await loadPreviewJob(client, auth, finalJobId);
|
||||
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
idempotent: true,
|
||||
insertedCount: 0,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (job.error_count > 0 && !allowPartial) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'rejected', error_message = 'Preview contains validation errors', updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
row_no: number;
|
||||
normalized_payload: NormalizedQuestion;
|
||||
}>(
|
||||
`
|
||||
select id, row_no, normalized_payload
|
||||
from public.content_import_items
|
||||
where tenant_id = $1 and job_id = $2 and status = 'valid'
|
||||
order by row_no asc
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
for (const item of itemResult.rows) {
|
||||
const status = await importOneQuestion(client, auth, job, item);
|
||||
if (status === 'inserted') insertedCount += 1;
|
||||
if (status === 'updated') updatedCount += 1;
|
||||
if (status === 'skipped') skippedCount += 1;
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
inserted_count = $4,
|
||||
updated_count = $5,
|
||||
skipped_count = $6,
|
||||
summary = coalesce(summary, '{}'::jsonb) || $7::jsonb,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
finalStatus,
|
||||
insertedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.import.questions.completed', 'content_import_job', $3, $4::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, job.id, JSON.stringify({ insertedCount, updatedCount, skippedCount, allowPartial })],
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: finalStatus,
|
||||
insertedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
errorCount: job.error_count,
|
||||
warningCount: job.warning_count,
|
||||
};
|
||||
});
|
||||
|
||||
return { item: result, preview: createdPreview };
|
||||
}
|
||||
|
||||
export async function importJobsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const importType = stringParam(ctx, 'importType');
|
||||
const status = stringParam(ctx, 'status');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (importType) {
|
||||
params.push(importType);
|
||||
filters.push(`import_type = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, import_type as "importType", source_format as "sourceFormat",
|
||||
status, source_name as "sourceName", source_hash as "sourceHash",
|
||||
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
|
||||
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
dry_run as "dryRun", total_count as "totalCount",
|
||||
valid_count as "validCount", error_count as "errorCount",
|
||||
warning_count as "warningCount", inserted_count as "insertedCount",
|
||||
updated_count as "updatedCount", skipped_count as "skippedCount",
|
||||
summary, error_message as "errorMessage",
|
||||
started_at as "startedAt", finished_at as "finishedAt",
|
||||
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.content_import_jobs
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function importIssuesRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const job = await queryOne<{ id: string }>(
|
||||
'select id from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select i.id, i.row_no as "rowNo", i.severity, i.code,
|
||||
i.field_path as "fieldPath", i.message, i.details,
|
||||
item.external_id as "externalId", item.status as "itemStatus",
|
||||
i.created_at as "createdAt"
|
||||
from public.content_import_issues i
|
||||
left join public.content_import_items item on item.id = i.item_id
|
||||
where i.tenant_id = $1 and i.job_id = $2
|
||||
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, jobId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
72
apps/api/src/features/tenant-content/index.ts
Normal file
72
apps/api/src/features/tenant-content/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
assetsAdminRoute,
|
||||
signAssetDownloadAdminRoute,
|
||||
signAssetUploadRoute,
|
||||
upsertAssetRoute,
|
||||
} from './assets.js';
|
||||
import {
|
||||
importIssuesRoute,
|
||||
importJobsRoute,
|
||||
importQuestionsRoute,
|
||||
previewQuestionsImportRoute,
|
||||
} from './imports.js';
|
||||
import {
|
||||
bindQuestionVideoRoute,
|
||||
createQuestionRoute,
|
||||
handbookChaptersAdminRoute,
|
||||
handbookEntriesAdminRoute,
|
||||
handbookSubjectsAdminRoute,
|
||||
scorelineFieldsAdminRoute,
|
||||
scorelineMajorsAdminRoute,
|
||||
scorelineRecordsAdminRoute,
|
||||
scorelineSchoolsAdminRoute,
|
||||
updateQuestionRoute,
|
||||
upsertHandbookChapterRoute,
|
||||
upsertHandbookEntryRoute,
|
||||
upsertHandbookSubjectRoute,
|
||||
upsertScorelineFieldRoute,
|
||||
upsertScorelineMajorRoute,
|
||||
upsertScorelineRecordRoute,
|
||||
upsertScorelineSchoolRoute,
|
||||
upsertVideoRoute,
|
||||
upsertVocabularyUnitRoute,
|
||||
upsertVocabularyWordRoute,
|
||||
videosAdminRoute,
|
||||
vocabularyUnitsAdminRoute,
|
||||
vocabularyWordsAdminRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/questions', createQuestionRoute],
|
||||
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
|
||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
|
||||
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
|
||||
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
|
||||
['GET', '/api/tenant-content/imports', importJobsRoute],
|
||||
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
|
||||
['GET', '/api/tenant-content/videos', videosAdminRoute],
|
||||
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
|
||||
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
|
||||
['GET', '/api/tenant-content/scoreline/schools', scorelineSchoolsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/schools', upsertScorelineSchoolRoute],
|
||||
['GET', '/api/tenant-content/scoreline/majors', scorelineMajorsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/majors', upsertScorelineMajorRoute],
|
||||
['GET', '/api/tenant-content/scoreline/fields', scorelineFieldsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/fields', upsertScorelineFieldRoute],
|
||||
['GET', '/api/tenant-content/scoreline/records', scorelineRecordsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/records', upsertScorelineRecordRoute],
|
||||
['GET', '/api/tenant-content/vocabulary-units', vocabularyUnitsAdminRoute],
|
||||
['PUT', '/api/tenant-content/vocabulary-units', upsertVocabularyUnitRoute],
|
||||
['GET', '/api/tenant-content/vocabulary-words', vocabularyWordsAdminRoute],
|
||||
['PUT', '/api/tenant-content/vocabulary-words', upsertVocabularyWordRoute],
|
||||
['GET', '/api/tenant-content/handbook-subjects', handbookSubjectsAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-subjects', upsertHandbookSubjectRoute],
|
||||
['GET', '/api/tenant-content/handbook-chapters', handbookChaptersAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-chapters', upsertHandbookChapterRoute],
|
||||
['GET', '/api/tenant-content/handbook-entries', handbookEntriesAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-entries', upsertHandbookEntryRoute],
|
||||
];
|
||||
820
apps/api/src/features/tenant-content/routes.ts
Normal file
820
apps/api/src/features/tenant-content/routes.ts
Normal file
@@ -0,0 +1,820 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { requireTenantContentEditor } from './auth.js';
|
||||
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js';
|
||||
|
||||
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
|
||||
|
||||
export async function createQuestionRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const questionResult = await client.query(
|
||||
`
|
||||
insert into public.questions (
|
||||
tenant_id, question_bank_id, subject_id, category_id, node_id,
|
||||
legacy_id, type, type_label, difficulty, tags, media_url, status
|
||||
)
|
||||
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
|
||||
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
|
||||
status, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.questionBankId),
|
||||
nullableString(body.subjectId),
|
||||
nullableString(body.categoryId),
|
||||
nullableString(body.nodeId),
|
||||
nullableString(body.legacyId),
|
||||
optionalString(body, 'type') || 'choice',
|
||||
optionalString(body, 'typeLabel') || null,
|
||||
intValue(body.difficulty, 1),
|
||||
jsonArrayValue(body.tags),
|
||||
nullableString(body.mediaUrl),
|
||||
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
|
||||
],
|
||||
);
|
||||
const question = questionResult.rows[0];
|
||||
|
||||
const versionResult = await client.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13)
|
||||
returning id, version_no as "versionNo", content, options,
|
||||
correct_option_index as "correctOptionIndex",
|
||||
correct_option_indices as "correctOptionIndices",
|
||||
answer_text as "answerText", explanation, sub_questions as "subQuestions",
|
||||
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
question.id,
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.options),
|
||||
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
|
||||
jsonArrayValue(body.correctOptionIndices),
|
||||
nullableString(body.answerText),
|
||||
nullableString(body.explanation),
|
||||
jsonArrayValue(body.subQuestions),
|
||||
nullableString(body.codeLang),
|
||||
nullableString(body.codeTemplate),
|
||||
nullableString(body.sourceHash),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, question.id, versionResult.rows[0].id],
|
||||
);
|
||||
|
||||
return { ...question, currentVersion: versionResult.rows[0] };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateQuestionRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const createVersion = body.createVersion === true;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const questionResult = await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = coalesce($3::uuid, question_bank_id),
|
||||
subject_id = coalesce($4::uuid, subject_id),
|
||||
category_id = coalesce($5::uuid, category_id),
|
||||
node_id = coalesce($6::uuid, node_id),
|
||||
type = coalesce(nullif($7, ''), type),
|
||||
type_label = coalesce(nullif($8, ''), type_label),
|
||||
difficulty = coalesce($9, difficulty),
|
||||
tags = case when $10::boolean then $11::jsonb else tags end,
|
||||
media_url = coalesce(nullif($12, ''), media_url),
|
||||
status = coalesce(nullif($13, ''), status),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
|
||||
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nullableString(body.questionBankId),
|
||||
nullableString(body.subjectId),
|
||||
nullableString(body.categoryId),
|
||||
nullableString(body.nodeId),
|
||||
optionalString(body, 'type'),
|
||||
optionalString(body, 'typeLabel'),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
Object.hasOwn(body, 'tags'),
|
||||
jsonArrayValue(body.tags),
|
||||
nullableString(body.mediaUrl),
|
||||
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
|
||||
],
|
||||
);
|
||||
|
||||
const question = questionResult.rows[0];
|
||||
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
|
||||
if (!createVersion) return question;
|
||||
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[questionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
|
||||
const versionResult = await client.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id, version_no as "versionNo", content, options,
|
||||
correct_option_index as "correctOptionIndex",
|
||||
correct_option_indices as "correctOptionIndices",
|
||||
answer_text as "answerText", explanation, sub_questions as "subQuestions",
|
||||
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nextVersionNo,
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.options),
|
||||
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
|
||||
jsonArrayValue(body.correctOptionIndices),
|
||||
nullableString(body.answerText),
|
||||
nullableString(body.explanation),
|
||||
jsonArrayValue(body.subQuestions),
|
||||
nullableString(body.codeLang),
|
||||
nullableString(body.codeTemplate),
|
||||
nullableString(body.sourceHash),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, questionId, versionResult.rows[0].id],
|
||||
);
|
||||
|
||||
return { ...question, currentVersion: versionResult.rows[0] };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function videosAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", title, description, video_url as "videoUrl",
|
||||
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
|
||||
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
|
||||
subject_id as "subjectId", difficulty, sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.video_explanations
|
||||
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
|
||||
order by sort_order asc, created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, subjectId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVideoRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const id = nullableString(body.id);
|
||||
const title = requiredString(body, 'title');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.video_explanations (
|
||||
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
|
||||
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
|
||||
sort_order, is_active
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7,
|
||||
$8, $9::jsonb, $10, $11::uuid, $12, $13, $14
|
||||
)
|
||||
on conflict (id)
|
||||
do update set title = excluded.title,
|
||||
description = excluded.description,
|
||||
video_url = excluded.video_url,
|
||||
thumbnail_url = excluded.thumbnail_url,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
knowledge_tags = excluded.knowledge_tags,
|
||||
is_general = excluded.is_general,
|
||||
subject_id = excluded.subject_id,
|
||||
difficulty = excluded.difficulty,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, title, description, video_url as "videoUrl",
|
||||
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
|
||||
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
|
||||
subject_id as "subjectId", difficulty, sort_order as "order",
|
||||
is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
id,
|
||||
auth.tenantId,
|
||||
nullableString(body.legacyId),
|
||||
title,
|
||||
nullableString(body.description),
|
||||
nullableString(body.videoUrl),
|
||||
nullableString(body.thumbnailUrl),
|
||||
body.duration === undefined ? null : intValue(body.duration, 0),
|
||||
jsonArrayValue(body.knowledgeTags),
|
||||
boolValue(body.isGeneral, false),
|
||||
nullableString(body.subjectId),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function bindQuestionVideoRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const videoId = requiredString(body, 'videoId');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]);
|
||||
if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]);
|
||||
if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.question_videos (
|
||||
tenant_id, question_id, video_id, legacy_id, video_type, sort_order
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, question_id as "questionId", video_id as "videoId",
|
||||
video_type as "videoType", sort_order as "order", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
videoId,
|
||||
nullableString(body.legacyId),
|
||||
optionalString(body, 'videoType') || 'specific',
|
||||
intValue(body.order, 0),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
]);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineSchoolsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, short_name as "shortName", type,
|
||||
is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_schools
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
[auth.tenantId, regionId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineSchoolRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
short_name = excluded.short_name,
|
||||
type = excluded.type,
|
||||
is_hot = excluded.is_hot,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, short_name as "shortName",
|
||||
type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.shortName),
|
||||
nullableString(body.type),
|
||||
boolValue(body.isHot, false),
|
||||
intValue(body.order, 0),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineMajorsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId", name,
|
||||
sort_order as "order", has_restriction as "hasRestriction",
|
||||
restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_majors
|
||||
where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid)
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
[auth.tenantId, schoolId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineMajorRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_majors (
|
||||
id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
name = excluded.name,
|
||||
sort_order = excluded.sort_order,
|
||||
has_restriction = excluded.has_restriction,
|
||||
restriction_desc = excluded.restriction_desc,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", school_id as "schoolId", name,
|
||||
sort_order as "order", has_restriction as "hasRestriction",
|
||||
restriction_desc as "restrictionDesc", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
requiredString(body, 'schoolId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.hasRestriction, false),
|
||||
nullableString(body.restrictionDesc),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineFieldsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", field_key as "fieldKey",
|
||||
field_name as "fieldName", field_type as "fieldType", unit,
|
||||
is_filter as "isFilter", is_required as "isRequired",
|
||||
is_visible as "isVisible", is_trend as "isTrend",
|
||||
options, placeholder, description, sort_order as "sortOrder"
|
||||
from public.scoreline_fields
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc
|
||||
`,
|
||||
[auth.tenantId, regionId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineFieldRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_fields (
|
||||
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
|
||||
unit, is_filter, is_required, is_visible, is_trend, options,
|
||||
placeholder, description, sort_order
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16
|
||||
)
|
||||
on conflict (tenant_id, region_id, field_key)
|
||||
do update set field_name = excluded.field_name,
|
||||
field_type = excluded.field_type,
|
||||
unit = excluded.unit,
|
||||
is_filter = excluded.is_filter,
|
||||
is_required = excluded.is_required,
|
||||
is_visible = excluded.is_visible,
|
||||
is_trend = excluded.is_trend,
|
||||
options = excluded.options,
|
||||
placeholder = excluded.placeholder,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", field_key as "fieldKey",
|
||||
field_name as "fieldName", field_type as "fieldType",
|
||||
unit, is_filter as "isFilter", is_required as "isRequired",
|
||||
is_visible as "isVisible", is_trend as "isTrend",
|
||||
options, placeholder, description, sort_order as "sortOrder",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'fieldKey'),
|
||||
requiredString(body, 'fieldName'),
|
||||
optionalString(body, 'fieldType') || 'number',
|
||||
nullableString(body.unit),
|
||||
boolValue(body.isFilter, false),
|
||||
boolValue(body.isRequired, false),
|
||||
boolValue(body.isVisible, true),
|
||||
boolValue(body.isTrend, false),
|
||||
jsonArrayValue(body.options),
|
||||
nullableString(body.placeholder),
|
||||
nullableString(body.description),
|
||||
intValue(body.sortOrder, 0),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineRecordsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by year desc, school_name asc, major_name asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, regionId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineRecordRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_records (
|
||||
id, tenant_id, region_id, school_id, major_id, legacy_id,
|
||||
year, school_name, major_name, field_values
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
major_id = excluded.major_id,
|
||||
year = excluded.year,
|
||||
school_name = excluded.school_name,
|
||||
major_name = excluded.major_name,
|
||||
field_values = excluded.field_values,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.schoolId),
|
||||
nullableString(body.majorId),
|
||||
nullableString(body.legacyId),
|
||||
intValue(body.year, new Date().getFullYear()),
|
||||
nullableString(body.schoolName),
|
||||
nullableString(body.majorName),
|
||||
jsonObjectValue(body.fieldValues),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_units
|
||||
where tenant_id = $1
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_units (
|
||||
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
word_count = excluded.word_count,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.description),
|
||||
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
const items = await query(
|
||||
`
|
||||
select id, unit_id as "unitId", word, phonetic, meaning, example,
|
||||
example_translation as "exampleTranslation", difficulty, tags,
|
||||
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_words
|
||||
where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid)
|
||||
order by sort_order asc, word asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, unitId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_words (
|
||||
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
|
||||
example, example_translation, difficulty, tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
|
||||
on conflict (id)
|
||||
do update set unit_id = excluded.unit_id,
|
||||
word = excluded.word,
|
||||
phonetic = excluded.phonetic,
|
||||
meaning = excluded.meaning,
|
||||
example = excluded.example,
|
||||
example_translation = excluded.example_translation,
|
||||
difficulty = excluded.difficulty,
|
||||
tags = excluded.tags,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, unit_id as "unitId", word, phonetic, meaning,
|
||||
example, example_translation as "exampleTranslation",
|
||||
difficulty, tags, sort_order as "order", is_active as "isActive",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.unitId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'word'),
|
||||
nullableString(body.phonetic),
|
||||
nullableString(body.meaning),
|
||||
nullableString(body.example),
|
||||
nullableString(body.exampleTranslation),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
jsonArrayValue(body.tags),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, type, icon, color,
|
||||
description, sort_order as "order", is_active as "isActive", metadata
|
||||
from public.handbook_subjects
|
||||
where tenant_id = $1
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_subjects (
|
||||
id, tenant_id, region_id, legacy_id, name, type, icon, color,
|
||||
description, sort_order, is_active, metadata
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
icon = excluded.icon,
|
||||
color = excluded.color,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, type, icon, color,
|
||||
description, sort_order as "order", is_active as "isActive",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.type),
|
||||
nullableString(body.icon),
|
||||
nullableString(body.color),
|
||||
nullableString(body.description),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
jsonObjectValue(body.metadata),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookChaptersAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, subject_id as "subjectId", name, description,
|
||||
sort_order as "order", is_active as "isActive"
|
||||
from public.handbook_chapters
|
||||
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId, subjectId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookChapterRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_chapters (
|
||||
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
|
||||
on conflict (id)
|
||||
do update set subject_id = excluded.subject_id,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, subject_id as "subjectId", name, description,
|
||||
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
requiredString(body, 'subjectId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.description),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookEntriesAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const chapterId = stringParam(ctx, 'chapterId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, chapter_id as "chapterId", title, summary, content,
|
||||
tags, sort_order as "order", is_active as "isActive"
|
||||
from public.handbook_entries
|
||||
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId, chapterId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookEntryRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_entries (
|
||||
id, tenant_id, chapter_id, legacy_id, title, summary, content,
|
||||
tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
on conflict (id)
|
||||
do update set chapter_id = excluded.chapter_id,
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
content = excluded.content,
|
||||
tags = excluded.tags,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, chapter_id as "chapterId", title, summary, content,
|
||||
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
requiredString(body, 'chapterId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'title'),
|
||||
nullableString(body.summary),
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.tags),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
32
apps/api/src/features/tenant-content/utils.ts
Normal file
32
apps/api/src/features/tenant-content/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function jsonObjectValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
}
|
||||
|
||||
export function jsonArrayValue(value: unknown) {
|
||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
||||
}
|
||||
|
||||
export function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function intValue(value: unknown, fallback: number) {
|
||||
const numberValue = Number(value ?? fallback);
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
|
||||
}
|
||||
|
||||
export function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function optionalStatus(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid status: ${candidate}`, 'INVALID_STATUS');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
6
apps/api/src/features/tenant/index.ts
Normal file
6
apps/api/src/features/tenant/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { resolveTenantRoute } from './routes.js';
|
||||
|
||||
export const tenantRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant/resolve', resolveTenantRoute],
|
||||
];
|
||||
108
apps/api/src/features/tenant/routes.ts
Normal file
108
apps/api/src/features/tenant/routes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { config } from '../../core/config.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { getHeader, type RequestContext } from '../../core/http.js';
|
||||
|
||||
interface TenantResolveRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
host: string | null;
|
||||
brand_name: string | null;
|
||||
short_name: string | null;
|
||||
slogan: string | null;
|
||||
logo_url: string | null;
|
||||
favicon_url: string | null;
|
||||
service_wechat: string | null;
|
||||
service_account_name: string | null;
|
||||
theme: Record<string, unknown>;
|
||||
feature_flags: Record<string, unknown>;
|
||||
admin_feature_flags: Record<string, unknown>;
|
||||
public_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string) {
|
||||
return host.split(':')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
const hostParam = ctx.url.searchParams.get('host') || '';
|
||||
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
|
||||
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
|
||||
|
||||
const row = tenantCode
|
||||
? await queryOne<TenantResolveRow>(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
null::text as host,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_settings s on s.tenant_id = t.id
|
||||
where t.slug = $1 and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantCode],
|
||||
)
|
||||
: await queryOne<TenantResolveRow>(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
d.host::text,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
from public.tenant_domains d
|
||||
join public.tenants t on t.id = d.tenant_id
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_settings s on s.tenant_id = t.id
|
||||
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[requestHost || 'localhost'],
|
||||
);
|
||||
|
||||
if (!row && requestHost !== 'localhost') {
|
||||
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
|
||||
return resolveTenantRoute(ctx);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
found: false,
|
||||
message: 'Tenant not found',
|
||||
lookup: { host: requestHost, tenantCode: tenantCode || null },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
tenant: {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
mode: row.mode,
|
||||
host: row.host,
|
||||
},
|
||||
branding: {
|
||||
brandName: row.brand_name || row.name,
|
||||
shortName: row.short_name || row.name,
|
||||
slogan: row.slogan || '',
|
||||
logoUrl: row.logo_url || '',
|
||||
faviconUrl: row.favicon_url || '',
|
||||
serviceWechat: row.service_wechat || '',
|
||||
serviceAccountName: row.service_account_name || '',
|
||||
theme: row.theme || {},
|
||||
},
|
||||
features: row.feature_flags || {},
|
||||
adminFeatures: row.admin_feature_flags || {},
|
||||
publicConfig: row.public_config || {},
|
||||
};
|
||||
}
|
||||
8
apps/api/src/features/video/index.ts
Normal file
8
apps/api/src/features/video/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js';
|
||||
|
||||
export const videoRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/questions/videos', questionVideosRoute],
|
||||
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
|
||||
['GET', '/api/videos/search', videoSearchRoute],
|
||||
];
|
||||
111
apps/api/src/features/video/routes.ts
Normal file
111
apps/api/src/features/video/routes.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
|
||||
import { query } from '../../core/db.js';
|
||||
|
||||
interface QuestionVideoRow {
|
||||
questionId: string;
|
||||
videoType: string;
|
||||
order: number;
|
||||
id: string;
|
||||
legacyId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
videoUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
duration: number | null;
|
||||
knowledgeTags: unknown[];
|
||||
isGeneral: boolean;
|
||||
subjectId: string | null;
|
||||
difficulty: number | null;
|
||||
}
|
||||
|
||||
function videoSelectSql() {
|
||||
return `
|
||||
select qv.question_id as "questionId", qv.video_type as "videoType",
|
||||
qv.sort_order as "order",
|
||||
v.id, v.legacy_id as "legacyId", v.title, v.description,
|
||||
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
|
||||
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
|
||||
v.is_general as "isGeneral", v.subject_id as "subjectId",
|
||||
v.difficulty
|
||||
from public.question_videos qv
|
||||
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function questionVideosRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const questionId = stringParam(ctx, 'questionId');
|
||||
if (!questionId) {
|
||||
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
|
||||
}
|
||||
|
||||
const videos = await query<QuestionVideoRow>(
|
||||
`
|
||||
${videoSelectSql()}
|
||||
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
|
||||
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
|
||||
`,
|
||||
[tenantId, questionId],
|
||||
);
|
||||
|
||||
return { videos, total: videos.length };
|
||||
}
|
||||
|
||||
export async function questionVideosBatchRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
|
||||
|
||||
if (!questionIds.length) {
|
||||
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
|
||||
}
|
||||
|
||||
const rows = await query<QuestionVideoRow>(
|
||||
`
|
||||
${videoSelectSql()}
|
||||
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
|
||||
order by qv.question_id, qv.sort_order asc, v.sort_order asc
|
||||
`,
|
||||
[tenantId, questionIds],
|
||||
);
|
||||
|
||||
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
|
||||
for (const row of rows) {
|
||||
data[row.questionId] ||= { hasVideo: true, videos: [] };
|
||||
data[row.questionId].videos.push(row);
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
export async function videoSearchRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const tags = stringParam(ctx, 'tags')
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const videos = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", title, description,
|
||||
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
|
||||
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
|
||||
is_general as "isGeneral", subject_id as "subjectId",
|
||||
difficulty, sort_order as "order", created_at as "createdAt"
|
||||
from public.video_explanations
|
||||
where tenant_id = $1
|
||||
and is_active = true
|
||||
and is_general = true
|
||||
and ($2::uuid is null or subject_id = $2::uuid)
|
||||
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
|
||||
order by sort_order asc, created_at desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, subjectId || null, tags, limit],
|
||||
);
|
||||
|
||||
return { videos, total: videos.length };
|
||||
}
|
||||
48
apps/api/src/server.ts
Normal file
48
apps/api/src/server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import http from 'node:http';
|
||||
import { config } from './core/config.js';
|
||||
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
|
||||
import { createRouter } from './core/router.js';
|
||||
|
||||
const routes = createRouter();
|
||||
|
||||
function resolveHandler(method: string | undefined, url: URL) {
|
||||
const exact = routes.get(routeKey(method, url.pathname));
|
||||
if (exact) return exact;
|
||||
|
||||
const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/);
|
||||
if (method === 'GET' && questionVideosMatch?.[1]) {
|
||||
url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1]));
|
||||
return routes.get(routeKey(method, '/api/questions/videos'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
applyCors(req, res);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const handler = resolveHandler(req.method, url);
|
||||
|
||||
if (!handler) {
|
||||
sendJson(res, 404, { error: 'Not found', path: url.pathname });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler({ req, res, url });
|
||||
sendJson(res, 200, result);
|
||||
} catch (error) {
|
||||
const { statusCode, body } = publicErrorBody(error);
|
||||
sendJson(res, statusCode, body);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
|
||||
});
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "../..",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
|
||||
}
|
||||
25
docker-compose.api.yml
Normal file
25
docker-compose.api.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: tiku-saas-dev
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgresql://postgres:postgres@host.docker.internal:54322/postgres
|
||||
DEFAULT_TENANT_SLUG: master
|
||||
CORS_ORIGIN: http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
ports:
|
||||
- "8787:8787"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- wget -qO- http://127.0.0.1:8787/health >/dev/null 2>&1 || exit 1
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
10917
docs/pb_schema.json
Normal file
10917
docs/pb_schema.json
Normal file
File diff suppressed because it is too large
Load Diff
28
docs/refactor/README.md
Normal file
28
docs/refactor/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# SaaS 重构工作区
|
||||
|
||||
这个目录记录从 PocketBase 单体项目迁移到 Supabase/PostgreSQL + 新 API + Taro 学生端的重构过程。
|
||||
|
||||
当前第一阶段目标:
|
||||
|
||||
- 本地 Supabase 能启动并创建多租户 PostgreSQL schema。
|
||||
- 新 API 能连接数据库并解析租户。
|
||||
- PocketBase 的 `docs/pb_schema.json` 能被解析,后续真实数据导出后可导入到新库。
|
||||
- 先保留旧 React Web 项目,逐步把学生端和后台接到新 API。
|
||||
|
||||
关键文件:
|
||||
|
||||
- `supabase/config.toml`:本地 Supabase 配置。
|
||||
- `supabase/migrations/202606210001_core_multitenant_schema.sql`:第一版商用级多租户 schema。
|
||||
- `apps/api`:新业务 API 服务,内部按 `src/core` 和 `src/features` 分层。
|
||||
- `packages/config`、`packages/db`、`packages/domain`:新系统共享基础包。
|
||||
- `scripts/import-pocketbase`:PocketBase schema/数据导入工具。
|
||||
- `docker-compose.api.yml`、`apps/api/Dockerfile`:本地 Docker API 运行入口。
|
||||
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
|
||||
|
||||
下一步优先级:
|
||||
|
||||
1. 导出 PocketBase 真实数据到 `pb_export/*.json`。
|
||||
2. 执行 `npm run pb:import:json` 和 `npm run pb:import:validate`。
|
||||
3. 按学生端页面逐步从 PocketBase SDK 切换到 `src/services/supabaseApi.ts`。
|
||||
4. 为订单、支付、权益开通补齐 API 写入流程和 webhook 幂等处理。
|
||||
5. 新建 Taro 学生端时复用同一套租户解析和业务 API,不另起一套后端。
|
||||
70
docs/refactor/api-structure.md
Normal file
70
docs/refactor/api-structure.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# API 目录规范
|
||||
|
||||
`apps/api` 是 Web、Taro 小程序、管理后台共用的业务 API。所有复杂业务写入都进入这里,前端不直接写 Supabase 表。
|
||||
|
||||
## 当前结构
|
||||
|
||||
```text
|
||||
apps/api/src/
|
||||
server.ts HTTP 服务入口,只负责请求生命周期
|
||||
core/
|
||||
config.ts 环境变量和运行配置
|
||||
db.ts PostgreSQL 连接池和查询封装
|
||||
http.ts CORS、JSON 响应、统一错误
|
||||
router.ts 汇总注册各业务域路由
|
||||
features/
|
||||
auth/ 短信验证码、迁移期 session、OAuth provider 预留
|
||||
health/ 健康检查
|
||||
tenant/ 租户解析、品牌配置、域名识别
|
||||
catalog/ 公开题库、科目、手册、商城、资料资源只读接口
|
||||
learning/ 答题、错题、收藏、练习 session
|
||||
commerce/ 订单、支付确认、激活码、权益
|
||||
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
||||
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
|
||||
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
|
||||
tenant-content/ 租户后台内容维护:题目、视频、分数线、单词、知识手册、资料资源、批量导入
|
||||
```
|
||||
|
||||
## 新业务域落位
|
||||
|
||||
后续按下面方式增加目录:
|
||||
|
||||
```text
|
||||
features/
|
||||
auth/ 登录、绑定手机、OAuth 回调、会话换取
|
||||
learning/ 答题记录、错题、收藏、学习进度
|
||||
commerce/ 商品、订单、支付、退款、权益开通
|
||||
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
|
||||
platform-admin/ 平台租户管理、年费、服务费、账务审计
|
||||
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券
|
||||
tenant-content/ 合作商内容维护、批量导入、资源绑定、内容审计
|
||||
```
|
||||
|
||||
每个 feature 默认包含:
|
||||
|
||||
```text
|
||||
index.ts 导出 RouteDefinition[]
|
||||
routes.ts HTTP handler
|
||||
service.ts 业务编排和事务
|
||||
repository.ts SQL 查询和写入
|
||||
types.ts 仅本领域使用的类型
|
||||
```
|
||||
|
||||
## 规则
|
||||
|
||||
- `server.ts` 不直接 import 业务 handler,只 import `createRouter()`。
|
||||
- `features/*/index.ts` 只注册路由,不写 SQL。
|
||||
- `routes.ts` 做参数解析、鉴权上下文、HTTP 错误,不写复杂事务。
|
||||
- `service.ts` 承接订单、支付、权益、答题判定等业务规则。
|
||||
- `repository.ts` 才写 SQL,所有 SQL 必须带明确租户边界。
|
||||
- 可预期错误用 `HttpError`,生产环境不向前端暴露内部异常。
|
||||
- 写接口必须考虑幂等、审计和租户隔离;支付 webhook 必须先设计幂等键。
|
||||
- 迁移期接口可用 `x-user-id` 标识学生用户;接 Supabase Auth 后统一替换为 JWT 解析。
|
||||
- 登录类接口先使用 `Authorization: Bearer tk_*` 迁移期 session;session 明文只返回客户端,数据库只保存 hash。
|
||||
- 平台运营接口使用 `x-platform-admin-key` 作为临时保护;正式上线前要迁到平台管理员 JWT 和审计日志。
|
||||
- `platform-admin` 管平台与合作商之间的 SaaS 账务,`tenant-admin` 管合作商自己的品牌、域名、公开配置、登录/商户配置、活动和兑换码,`tenant-content` 管合作商自己的题库和学习内容维护。
|
||||
- `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`,商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault;对前端只返回 `secretRef` 和掩码状态。
|
||||
- `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。
|
||||
- `referral` 是增长/客资业务域,负责邀请码、扫码事件、首绑保护、销售/代理团队归属和 CRM 入队;真实 CRM webhook 发送应由 worker 处理,API 只负责幂等入队。
|
||||
- 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 地址。
|
||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录;同步 API 当前支持题目 JSON,Excel/CSV 和其它内容类型应接入同一管线。
|
||||
97
docs/refactor/architecture.md
Normal file
97
docs/refactor/architecture.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 重构工程结构
|
||||
|
||||
新系统按 SaaS 商用架构组织,不再把 PocketBase 旧项目作为长期主结构。旧 React/PocketBase 代码先保留为兼容层,新的后端、数据库、导入器和共享包独立放置。
|
||||
|
||||
## 目录边界
|
||||
|
||||
```text
|
||||
apps/
|
||||
api/ 新业务 API,前端和小程序都通过它访问业务数据
|
||||
src/core/ 配置、HTTP、错误响应、路由注册、数据库访问等基础层
|
||||
src/features/ 领域模块,按 catalog、tenant、health 等拆分
|
||||
src/features/platform-admin/
|
||||
平台方管理合作商租户、订阅、账单和使用量
|
||||
|
||||
packages/
|
||||
config/ 环境变量、默认租户、默认数据库连接等共享配置
|
||||
db/ PostgreSQL 连接池和 query/queryOne
|
||||
domain/ 租户角色、订单状态、权益范围等领域常量
|
||||
|
||||
supabase/
|
||||
config.toml 本地 Supabase 配置
|
||||
migrations/ PostgreSQL schema、RLS、触发器、索引
|
||||
seed.sql 本地主租户 seed
|
||||
|
||||
scripts/
|
||||
import-pocketbase/ PocketBase schema 风险分析、JSON 导入、导入后校验
|
||||
smoke-seed.js 本地 reset 后的最小业务烟测数据
|
||||
|
||||
src/
|
||||
services/supabaseApi.ts 旧 Web 前端迁向新 API 的兼容客户端
|
||||
tenant.config.ts 租户解析配置,优先读新 API
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
- `apps/api` 是业务 API 层,复杂交易、支付、权益、租户解析都应该在这里做,不让前端直接操作表。
|
||||
- 平台方与合作商之间的 SaaS 收费,使用 `platform_saas_plans`、`tenant_subscriptions`、`tenant_invoices`、`tenant_invoice_payments`;学生 C 端会员订单仍使用 `orders/payments/entitlements`。
|
||||
- `apps/api/src/server.ts` 只负责 HTTP 生命周期;业务路由统一放在 `features/*`,由 `core/router.ts` 汇总注册。
|
||||
- API 对外错误必须走 `HttpError` 或统一错误响应,生产环境不向前端泄露数据库异常和内部栈信息。
|
||||
- `packages/*` 放可复用基础能力,后续 Taro 小程序、管理后台 API、异步 worker 都复用这里。
|
||||
- `supabase/migrations` 是数据库事实来源,旧 PB 字段不能绕过迁移规范直接进正式表。
|
||||
- `scripts/import-pocketbase` 是一次性和可重复迁移工具,必须保持幂等,导入后必须跑验证。
|
||||
- `src/services/pocketbase.ts` 和 `src/services/mockBackend.ts` 属于旧兼容层,后续按页面逐步替换到 `src/services/supabaseApi.ts`。
|
||||
|
||||
## 本地开发顺序
|
||||
|
||||
```bash
|
||||
npm run supabase:start
|
||||
npm run supabase:reset
|
||||
npm run db:smoke-seed
|
||||
npm run dev:api
|
||||
```
|
||||
|
||||
也可以只把 API 放进 Docker 容器运行。Supabase 仍由 Supabase CLI 管理,API 容器通过宿主机端口连接本地 PostgreSQL:
|
||||
|
||||
```bash
|
||||
npm run supabase:start
|
||||
npm run docker:api:build
|
||||
npm run docker:api:up
|
||||
```
|
||||
|
||||
如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`。
|
||||
|
||||
验证 API:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/health
|
||||
curl "http://127.0.0.1:8787/api/tenant/resolve?host=localhost"
|
||||
curl "http://127.0.0.1:8787/api/catalog/regions?tenantId=00000000-0000-0000-0000-000000000001"
|
||||
```
|
||||
|
||||
导入旧数据:
|
||||
|
||||
```bash
|
||||
npm run pb:import:json
|
||||
npm run pb:import:validate
|
||||
```
|
||||
|
||||
## 当前已验证
|
||||
|
||||
- Docker Desktop 可用。
|
||||
- Supabase 本地容器可启动。
|
||||
- `supabase db reset` 可完整执行三份 migration 和 seed。
|
||||
- `supabase db reset` 可完整执行全部 migration 和 seed。
|
||||
- `npm run db:smoke-seed` 可恢复最小业务烟测数据。
|
||||
- `platform-admin` 可完成平台概览、租户创建、订阅、账单生成、人工收款确认、使用量记录。
|
||||
- API `/health` 可连 PostgreSQL 并返回 `db: ok`。
|
||||
- API `/api/tenant/resolve?host=localhost` 可解析主租户。
|
||||
- API 构建产物入口 `apps/api/dist/apps/api/src/server.js` 已验证可启动。
|
||||
- 空库执行 `pb:import:validate` 为 0 failures、0 warnings。
|
||||
|
||||
## 下一阶段拆分
|
||||
|
||||
- `apps/api/src/features` 继续按业务域扩展:真实支付 provider、真实 OAuth provider、平台审计和 worker。
|
||||
- `src/services/supabaseApi.ts` 逐页替换旧 PB 只读接口,优先学生端和小程序共用页面。
|
||||
- 新增 `apps/worker` 承接 CRM webhook、支付补偿、日报统计、导入后异步检查。
|
||||
- 新增 `apps/taro` 后,所有租户解析和公开业务读取都复用 API,不单独维护另一套后端逻辑。
|
||||
94
docs/refactor/auth-payment-provider-plan.md
Normal file
94
docs/refactor/auth-payment-provider-plan.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 国内认证与支付接入方案
|
||||
|
||||
## Supabase 边界
|
||||
|
||||
Supabase 适合承担 PostgreSQL、RLS、Auth、Edge Functions、Webhook/Hooks 等底座能力,但它不是中国大陆支付网关,也不会内置微信支付、支付宝、阿里云短信、腾讯云短信这一整套商用配置。
|
||||
|
||||
对本项目更稳妥的落位是:
|
||||
|
||||
- Supabase/PostgreSQL:保存多租户、订单、支付事件、权益、审计、登录事件。
|
||||
- `apps/api`:实现业务 API、短信 provider、OAuth provider、支付 provider、回调验签和幂等。
|
||||
- `app_private.tenant_secrets` 或生产 Vault/KMS:保存租户级密钥。
|
||||
- `tenant_auth_providers`、`tenant_payment_accounts`:只保存非敏感公开配置。
|
||||
|
||||
Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先用 `app_private.auth_sessions` 签发 `tk_` session,保证 Web/Taro 能跑通端到端流程。
|
||||
|
||||
## 当前已实现
|
||||
|
||||
- `POST /api/auth/sms/send`:手机号验证码发送,验证码只保存 HMAC hash。
|
||||
- `POST /api/auth/sms/verify`:验证码登录,自动创建或复用 `platform_users`。
|
||||
- `GET /api/auth/me`:通过 Bearer token 获取当前用户。
|
||||
- `POST /api/auth/logout`:吊销迁移期 session。
|
||||
- `POST /api/auth/oauth/wechat`、`/wechat-miniapp`、`/qq`:provider 占位,已固定错误码 `PROVIDER_NOT_CONFIGURED`。
|
||||
- `tenant_auth_providers`:租户级公开认证配置。
|
||||
- `sms_verification_codes`:验证码审计表,不保存明文 code。
|
||||
- `auth_login_events`:登录事件审计。
|
||||
- `app_private.auth_sessions`:迁移期 session token hash。
|
||||
|
||||
## 短信 Provider
|
||||
|
||||
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境如果仍为 mock,会直接拒绝发送。
|
||||
|
||||
后续真实 provider:
|
||||
|
||||
- `aliyun`:接阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。
|
||||
- `tencent`:接腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。
|
||||
|
||||
密钥策略:
|
||||
|
||||
- AccessKey/SecretKey 不进入 `tenant_settings.public_config`。
|
||||
- 租户级密钥写 `app_private.tenant_secrets(secret_scope='sms')` 或生产 Vault。
|
||||
- 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。
|
||||
|
||||
## 微信/QQ 登录
|
||||
|
||||
微信小程序登录应由前端传 `wx.login` code 到 `/api/auth/oauth/wechat-miniapp`,后端调用微信 `code2Session` 换取 openid/session_key/unionid,再落 `user_identities`。
|
||||
|
||||
微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature,不继续堆在 auth 模块里。
|
||||
|
||||
## 支付 Provider
|
||||
|
||||
支付不走 Supabase 内置能力。推荐继续扩展 `commerce`:
|
||||
|
||||
- `POST /api/commerce/orders` 只负责创建订单,金额以后端套餐为准。
|
||||
- `POST /api/commerce/payments/:provider/create` 后续按 provider 创建支付参数或收银台地址。
|
||||
- `POST /api/commerce/payments/:provider/notify` 统一落 `payment_events`,先验签、再幂等、再更新订单和权益。
|
||||
- 支付成功继续复用 `grantSvipEntitlement`,避免微信/支付宝/XPay 各写一套开通逻辑。
|
||||
|
||||
B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务:
|
||||
|
||||
- `platform_saas_plans`:平台售卖给合作商的 SaaS 套餐。
|
||||
- `tenant_subscriptions`:合作商当前订阅状态。
|
||||
- `tenant_invoices`、`tenant_invoice_items`:合作商账单与明细。
|
||||
- `tenant_invoice_payments`:合作商账单收款记录。
|
||||
- `tenant_usage_records`:学生数、题量、存储等用量指标。
|
||||
|
||||
支持策略:
|
||||
|
||||
- 平台代收:平台商户号收款,再给租户结算。
|
||||
- 租户自收:每个租户配置自己的商户号和密钥。
|
||||
- 服务商模式:平台服务商统一管理子商户。
|
||||
|
||||
真实接入前需要先明确微信支付、支付宝或聚合支付是否允许你们销售的题库会员形态,以及小程序端是否涉及虚拟支付限制。
|
||||
|
||||
## 需要准备的资料
|
||||
|
||||
- 阿里云或腾讯云短信:签名、模板 ID、AccessKey/SecretKey、短信用途文案。
|
||||
- 微信小程序:AppID、AppSecret、主体信息、合法域名、用户手机号授权能力。
|
||||
- 微信网页/公众号:AppID、AppSecret、授权回调域名。
|
||||
- QQ 互联:AppID、AppKey、回调域名。
|
||||
- 微信支付:商户号、API v3 key、商户证书/平台证书、回调域名、AppID 绑定关系。
|
||||
- 支付宝:AppID、应用私钥、支付宝公钥、回调地址、网页/手机网站/当面付产品开通情况。
|
||||
- 每个合作商租户的收款模式:平台代收、租户自收或服务商子商户。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Supabase Phone Login: https://supabase.com/docs/guides/auth/phone-login
|
||||
- Supabase Auth Hooks: https://supabase.com/docs/guides/auth/auth-hooks
|
||||
- Supabase Social Login: https://supabase.com/docs/guides/auth/social-login
|
||||
- 阿里云短信 SendSms: https://help.aliyun.com/zh/sms/developer-reference/api-dysmsapi-2017-05-25-sendsms
|
||||
- 腾讯云短信 SendSms: https://cloud.tencent.com/document/api/382/55981
|
||||
- 微信小程序登录 code2Session: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
|
||||
- QQ 互联 OAuth: https://wiki.connect.qq.com/oauth2-0简介
|
||||
- 微信支付 API v3: https://pay.weixin.qq.com/doc/v3/merchant/4012791855
|
||||
- 支付宝开放平台: https://opendocs.alipay.com/
|
||||
177
docs/refactor/backend-progress.md
Normal file
177
docs/refactor/backend-progress.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 后端重构进度
|
||||
|
||||
## 已完成
|
||||
|
||||
- Docker Desktop + Supabase local 已可用。
|
||||
- API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。
|
||||
- API 已按 `core/features` 分层:
|
||||
- `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。
|
||||
- `catalog`:公开题库、地区、科目、手册、商品、SVIP 套餐、资料资源只读/下载接口。
|
||||
- `learning`:练习 session、答题记录、错题、收藏、背单词进度/收藏/统计。
|
||||
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习。
|
||||
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||
- `commerce`:订单、支付确认、激活码兑换、权益查询。
|
||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
|
||||
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
|
||||
- `tenant-content`:租户后台题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。
|
||||
- `tenant`:域名/租户解析。
|
||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
||||
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
||||
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、租户隔离、资源权限和题目导入。
|
||||
|
||||
## 已验证接口
|
||||
|
||||
```text
|
||||
GET /health
|
||||
POST /api/auth/sms/send
|
||||
POST /api/auth/sms/verify
|
||||
GET /api/auth/me
|
||||
POST /api/auth/logout
|
||||
POST /api/auth/oauth/wechat
|
||||
POST /api/auth/oauth/wechat-miniapp
|
||||
POST /api/auth/oauth/qq
|
||||
GET /api/tenant/resolve
|
||||
GET /api/platform-admin/overview
|
||||
GET /api/platform-admin/plans
|
||||
GET /api/platform-admin/tenants
|
||||
POST /api/platform-admin/tenants
|
||||
GET /api/platform-admin/tenants/detail
|
||||
PATCH /api/platform-admin/tenants/status
|
||||
PUT /api/platform-admin/tenants/billing-profile
|
||||
POST /api/platform-admin/subscriptions
|
||||
GET /api/platform-admin/invoices
|
||||
POST /api/platform-admin/invoices
|
||||
POST /api/platform-admin/invoices/from-subscription
|
||||
POST /api/platform-admin/invoices/payments/manual-confirm
|
||||
GET /api/platform-admin/usage
|
||||
POST /api/platform-admin/usage
|
||||
GET /api/catalog/*
|
||||
GET /api/catalog/assets
|
||||
GET /api/catalog/assets/download
|
||||
POST /api/learning/answers
|
||||
GET /api/learning/favorites/questions
|
||||
POST /api/learning/favorites/questions
|
||||
GET /api/learning/wrong-questions
|
||||
GET /api/learning/vocabulary/progress
|
||||
POST /api/learning/vocabulary/progress
|
||||
GET /api/learning/vocabulary/favorites
|
||||
POST /api/learning/vocabulary/favorites
|
||||
GET /api/learning/vocabulary/stats
|
||||
GET /api/profile/me
|
||||
PATCH /api/profile/me
|
||||
GET /api/scoreline/fields
|
||||
GET /api/scoreline/schools
|
||||
GET /api/scoreline/majors
|
||||
GET /api/scoreline/records
|
||||
GET /api/scoreline/trend
|
||||
GET /api/scoreline/years
|
||||
GET /api/questions/{questionId}/videos
|
||||
POST /api/questions/videos/batch
|
||||
GET /api/videos/search
|
||||
POST /api/tenant-content/questions
|
||||
PATCH /api/tenant-content/questions
|
||||
GET /api/tenant-content/assets
|
||||
PUT /api/tenant-content/assets
|
||||
POST /api/tenant-content/assets/sign-upload
|
||||
POST /api/tenant-content/assets/sign-download
|
||||
POST /api/tenant-content/imports/preview/questions
|
||||
POST /api/tenant-content/imports/questions
|
||||
GET /api/tenant-content/imports
|
||||
GET /api/tenant-content/imports/issues
|
||||
PUT /api/tenant-content/videos
|
||||
POST /api/tenant-content/question-videos
|
||||
PUT /api/tenant-content/scoreline/schools
|
||||
PUT /api/tenant-content/scoreline/majors
|
||||
PUT /api/tenant-content/scoreline/fields
|
||||
PUT /api/tenant-content/scoreline/records
|
||||
PUT /api/tenant-content/vocabulary-units
|
||||
PUT /api/tenant-content/vocabulary-words
|
||||
PUT /api/tenant-content/handbook-subjects
|
||||
PUT /api/tenant-content/handbook-chapters
|
||||
PUT /api/tenant-content/handbook-entries
|
||||
POST /api/commerce/orders
|
||||
GET /api/commerce/orders
|
||||
POST /api/commerce/payments/manual-confirm
|
||||
POST /api/commerce/activation-codes/redeem
|
||||
GET /api/commerce/entitlements
|
||||
GET /api/commerce/entitlements/check
|
||||
POST /api/referral/invite-code
|
||||
POST /api/referral/resolve
|
||||
POST /api/referral/track-event
|
||||
POST /api/referral/bind
|
||||
GET /api/referral/stats
|
||||
GET /api/referral/sales-stats
|
||||
GET /api/referral/sales-clients
|
||||
POST /api/referral/manual-bind
|
||||
GET /api/referral/team
|
||||
PUT /api/referral/team
|
||||
POST /api/referral/qrcode
|
||||
GET /api/crm/config
|
||||
PUT /api/crm/config
|
||||
GET /api/crm/queue
|
||||
GET /api/tenant-admin/permissions
|
||||
GET /api/tenant-admin/overview
|
||||
PUT /api/tenant-admin/branding
|
||||
PUT /api/tenant-admin/settings
|
||||
GET /api/tenant-admin/domains
|
||||
POST /api/tenant-admin/domains
|
||||
GET /api/tenant-admin/payment-accounts
|
||||
PUT /api/tenant-admin/payment-accounts
|
||||
GET /api/tenant-admin/auth-providers
|
||||
PUT /api/tenant-admin/auth-providers
|
||||
GET /api/tenant-admin/secrets
|
||||
PUT /api/tenant-admin/secrets
|
||||
GET /api/tenant-admin/banners
|
||||
PUT /api/tenant-admin/banners
|
||||
GET /api/tenant-admin/faqs
|
||||
PUT /api/tenant-admin/faqs
|
||||
GET /api/tenant-admin/announcements
|
||||
PUT /api/tenant-admin/announcements
|
||||
GET /api/tenant-admin/code-batches
|
||||
PUT /api/tenant-admin/code-batches
|
||||
GET /api/tenant-admin/activation-codes
|
||||
PUT /api/tenant-admin/activation-codes
|
||||
POST /api/tenant-admin/activation-codes/generate
|
||||
GET /api/tenant-admin/coupons
|
||||
PUT /api/tenant-admin/coupons
|
||||
GET /api/tenant-admin/members
|
||||
PUT /api/tenant-admin/members
|
||||
POST /api/tenant-admin/members/disable
|
||||
GET /api/tenant-admin/audit-logs
|
||||
```
|
||||
|
||||
## 迁移期约定
|
||||
|
||||
- 当前写接口用 `x-tenant-id` 和 `x-user-id` 做迁移期上下文。
|
||||
- `auth` 当前签发迁移期 `tk_` session,token hash 存在 `app_private.auth_sessions`;后续接 Supabase Auth 后,`x-user-id` 要替换为 JWT 用户身份解析。
|
||||
- 短信验证码只保存 HMAC hash,不保存明文;本地 `mock` provider 才会返回 `debugCode`。
|
||||
- `platform-admin` 当前用 `x-platform-admin-key` 做迁移期保护,生产后必须替换为平台管理员 JWT/服务端会话。
|
||||
- B 端合作商年费/服务费使用 `tenant_invoices`、`tenant_invoice_items`、`tenant_invoice_payments`,不与 C 端学生订单混表。
|
||||
- 订单金额以后端套餐价格为准,不信任前端传价。
|
||||
- 激活码兑换和支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
||||
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限;真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
|
||||
- 题目批量导入当前支持 JSON 数组预览、逐行 issue、job/item 台账、执行导入和幂等跳过;Excel/CSV、单词/手册/分数线导入会复用同一套 `content_import_jobs` 管线。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 完善内容导入和文件上传:Excel/CSV、单词、手册、分数线、视频导入,真实 OSS/COS/Supabase Storage 签名。
|
||||
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||
4. 增加真实支付 provider:XPay、微信支付、支付宝,并完善 webhook 幂等。
|
||||
5. 增加 `apps/worker`:支付补偿、CRM webhook、日报统计、导入后检查。
|
||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||
|
||||
## 测试命令
|
||||
|
||||
```text
|
||||
npm run test:api
|
||||
npm run check:refactor
|
||||
```
|
||||
43
docs/refactor/blueprint-coverage.md
Normal file
43
docs/refactor/blueprint-coverage.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# SaaS 蓝图覆盖矩阵
|
||||
|
||||
更新时间:2026-06-21 21:42
|
||||
|
||||
## 目标定位
|
||||
|
||||
新项目要覆盖旧 PocketBase 项目全部功能,同时升级为多租户 SaaS:
|
||||
|
||||
- 平台超级管理员:管理所有租户、SaaS 套餐、年费/服务费、公共/地区题库披露。
|
||||
- 租户公司:拥有自己的品牌、域名、支付/登录/CRM 配置、成员角色、题库内容、销售/代理体系。
|
||||
- 学生端:刷题、错题、收藏、背单词、知识手册、分数线、视频解析、会员权益。
|
||||
- 跨端前端:后续 Taro 一套代码输出 H5 和小程序,统一调用 `apps/api`。
|
||||
|
||||
## 当前覆盖情况
|
||||
|
||||
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
|
||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||
| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 |
|
||||
| 题库内容维护 | 基础完成 | 题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分类/节点完整管理、公题库采纳/复制/授权 |
|
||||
| 学生刷题 | 基础完成 | 题目列表、练习 session、答题、错题本、收藏夹 | 模考、专项练习策略、错题复习计划、题型统计深度分析 |
|
||||
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计 | 复习算法、每日计划、排行榜 |
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 |
|
||||
| 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 |
|
||||
| 资料下载/PDF | 基础完成 | `content_assets` 资源台账、后台资源管理、上传/下载签名占位、学生端列表、SVIP 下载权限 | 真实 OSS/COS/Supabase Storage 签名、PDF 预览渲染、防盗链、资料前端管理页 |
|
||||
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告 | 勋章自动发放、复杂活动规则、核销报表 |
|
||||
| 销售/代理客资 | 基础完成 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、小程序码占位 | 真实微信小程序码、分佣结算单、销售团队看板、代理费结算 |
|
||||
| CRM 系统 | 基础完成 | CRM 配置、密钥私密存储、客资入队、队列查询 | worker 发送、钉钉/飞书/企微 adapter、重试签名、定向/轮询分配 |
|
||||
| 数据看板 | 数据表部分具备 | `dashboard_daily_stats`、`revenue_daily_stats` 表、旧 backfill 脚本 | API 聚合、24h 活跃、收入趋势、题型/科目/套餐销售看板 |
|
||||
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信/QQ 登录真实 adapter、Supabase Auth/JWT |
|
||||
| 支付 | 迁移期可用 | 订单、支付记录、手动确认、权益发放、租户商户配置 | 微信支付/支付宝/XPay adapter、webhook 幂等、退款 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
|
||||
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |
|
||||
|
||||
## 接下来优先级
|
||||
|
||||
1. 完善内容导入和对象存储:Excel/CSV、单词/手册/分数线/视频导入,真实 OSS/COS/Supabase Storage 签名。
|
||||
2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。
|
||||
3. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。
|
||||
4. 数据看板 API:把旧 dashboard/revenue 统计迁到新 API。
|
||||
5. 真实 provider:短信、微信/QQ 登录、微信支付/支付宝、CRM worker。
|
||||
65
docs/refactor/data-governance.md
Normal file
65
docs/refactor/data-governance.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 数据治理与安全规范
|
||||
|
||||
这次重构不按 PocketBase 旧字段原样搬迁。旧集合只作为历史输入源,正式业务表按商用 SaaS 规范重新建模。
|
||||
|
||||
## 默认原则
|
||||
|
||||
- 旧数据先进入 `pb_raw_records`,且默认脱敏。
|
||||
- 密钥、token、session、private key、AppSecret、支付密钥不得进入 `public` schema。
|
||||
- 私密配置进入 `app_private.tenant_secrets`,生产环境再接云厂商 KMS/Vault。
|
||||
- `settings` 不再作为业务表使用,必须拆成公开配置、支付账户、短信配置、OAuth 配置、存储配置。
|
||||
- `users` 不再作为万能表,拆成用户、身份、租户成员、学生资料、权益、学习记录。
|
||||
- `users.isSvip`、`svipExpiry`、`svipRegions` 不作为新系统权限源,统一迁移为 `entitlements`。
|
||||
- `users.stats.favorites/wrongBook` 不继续留在 JSON 中,统一迁移为收藏表和错题表。
|
||||
- `crm_config`、支付、短信、OAuth、对象存储等配置不得在 `public` schema 中保存明文密钥;公共表只保留可展示配置或 secret 引用。
|
||||
- 导入后的上线闸门是 `npm run pb:import:validate`:有 `FAIL` 不上线,`WARN` 必须由业务确认并记录。
|
||||
|
||||
## 禁止直接复制的字段类型
|
||||
|
||||
字段名包含以下关键词时默认视为敏感:
|
||||
|
||||
```text
|
||||
password
|
||||
token
|
||||
secret
|
||||
privateKey
|
||||
sessionKey
|
||||
accessKey
|
||||
appKey
|
||||
apiKey
|
||||
openid
|
||||
unionid
|
||||
aesKey
|
||||
notifyToken
|
||||
```
|
||||
|
||||
这些字段默认在导入原始区时写入 `[REDACTED]`。如果确实需要迁移到私有表,必须显式设置:
|
||||
|
||||
```bash
|
||||
IMPORT_SECRET_VALUES=true npm run import:json
|
||||
```
|
||||
|
||||
并且只能进入 `app_private.tenant_secrets`。
|
||||
|
||||
## 导入质量报告
|
||||
|
||||
导入时会写入 `pb_import_issues`,用于记录:
|
||||
|
||||
- 敏感字段来源
|
||||
- 旧 JSON 字段需要拆表
|
||||
- `settings` 大杂烩配置风险
|
||||
- `users` 会员状态需要转权益
|
||||
|
||||
查看旧 schema 风险:
|
||||
|
||||
```bash
|
||||
npm run pb:schema:risk
|
||||
```
|
||||
|
||||
## 后续硬性验收
|
||||
|
||||
- RLS 覆盖所有带 `tenant_id` 的表。
|
||||
- 租户间数据隔离测试必须自动化。
|
||||
- 支付 webhook 必须幂等。
|
||||
- 订单金额、支付流水、权益开通必须可审计。
|
||||
- 管理员操作必须写审计日志。
|
||||
240
docs/refactor/implementation-status.md
Normal file
240
docs/refactor/implementation-status.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Supabase 重构功能进度矩阵
|
||||
|
||||
更新时间:2026-06-21 21:42
|
||||
|
||||
## 当前结论
|
||||
|
||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、部分学生端 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目 JSON 批量导入基础闭环,以及本地 Docker/API 构建验证。
|
||||
|
||||
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。
|
||||
|
||||
## 新旧项目位置
|
||||
|
||||
| 范围 | 路径 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 旧 PocketBase/React 项目 | `F:\project\src`、`F:\project\pb_hooks`、`F:\project\pb_migrations` | 保留作为功能参照和迁移来源 |
|
||||
| 新 Node API | `F:\project\apps\api` | 已按 `core/features` 分层重构 |
|
||||
| Supabase/PostgreSQL 迁移 | `F:\project\supabase\migrations` | 已建立多租户和业务域表 |
|
||||
| PocketBase 数据导入 | `F:\project\scripts\import-pocketbase` | 已支持多类旧数据归一化导入与校验 |
|
||||
| 共享包 | `F:\project\packages\config`、`F:\project\packages\db`、`F:\project\packages\domain` | 已建立基础共享层 |
|
||||
| 旧 Web 到新 API 适配 | `F:\project\src\services\supabaseApi.ts` | 已开始抽象,后续应迁到 Taro 共享 API 包 |
|
||||
|
||||
## 功能完成度
|
||||
|
||||
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、分类、地区、科目、导入任务台账 | 已支持核心映射 | 题目列表、练习 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导入断言 | 基础刷题链路、后台题目录入和 JSON 批量导入可跑,专项练习/模考/Excel 导入仍需补齐 |
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
|
||||
| 背单词 | 已建单词单元、单词、进度、收藏表 | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API 已实现 | 核心 API 集成测试 | 学生端基础学习状态和后台单词维护已实现,复习算法和后台统计待完善 |
|
||||
| 知识手册 | 已建手册科目、章节、条目 | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API 已实现 | 核心 API 集成测试 | 学生端阅读和后台维护基础可用,富文本资源/版本管理待补 |
|
||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
|
||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、上传/下载签名占位、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载 | 资料资源基础闭环可跑,真实 OSS/COS 签名、PDF 预览渲染、资料下载前端待补 |
|
||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 |
|
||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
|
||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用,真实微信小程序码、分佣结算单、CRM worker 推送待补 |
|
||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
|
||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录已实现 | 仅烟测 | 平台收费链路骨架可用,正式鉴权/审计/自动计费未完成 |
|
||||
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、OAuth 占位已实现 | 仅烟测 | 本地可测,真实短信/微信/QQ 登录未完成 |
|
||||
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
|
||||
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
||||
|
||||
## 已实现 API 范围
|
||||
|
||||
```text
|
||||
auth:
|
||||
POST /api/auth/sms/send
|
||||
POST /api/auth/sms/verify
|
||||
GET /api/auth/me
|
||||
POST /api/auth/logout
|
||||
POST /api/auth/oauth/wechat
|
||||
POST /api/auth/oauth/wechat-miniapp
|
||||
POST /api/auth/oauth/qq
|
||||
|
||||
tenant:
|
||||
GET /api/tenant/resolve
|
||||
|
||||
catalog:
|
||||
GET /api/catalog/regions
|
||||
GET /api/catalog/region-modules
|
||||
GET /api/catalog/module-nodes
|
||||
GET /api/catalog/schools
|
||||
GET /api/catalog/majors
|
||||
GET /api/catalog/subjects
|
||||
GET /api/catalog/categories
|
||||
GET /api/catalog/questions
|
||||
GET /api/catalog/assets
|
||||
GET /api/catalog/assets/download
|
||||
GET /api/catalog/vocabulary-units
|
||||
GET /api/catalog/vocabulary-words
|
||||
GET /api/catalog/handbook-subjects
|
||||
GET /api/catalog/handbook-chapters
|
||||
GET /api/catalog/handbook-entries
|
||||
GET /api/catalog/banners
|
||||
GET /api/catalog/faqs
|
||||
GET /api/catalog/announcements
|
||||
GET /api/catalog/products
|
||||
GET /api/catalog/timelines
|
||||
GET /api/catalog/svip-plans
|
||||
|
||||
learning:
|
||||
POST /api/learning/practice-sessions
|
||||
POST /api/learning/answers
|
||||
GET /api/learning/favorites/questions
|
||||
POST /api/learning/favorites/questions
|
||||
GET /api/learning/wrong-questions
|
||||
POST /api/learning/wrong-questions/resolve
|
||||
GET /api/learning/vocabulary/progress
|
||||
POST /api/learning/vocabulary/progress
|
||||
GET /api/learning/vocabulary/favorites
|
||||
POST /api/learning/vocabulary/favorites
|
||||
GET /api/learning/vocabulary/stats
|
||||
|
||||
profile:
|
||||
GET /api/profile/me
|
||||
PATCH /api/profile/me
|
||||
|
||||
scoreline:
|
||||
GET /api/scoreline/fields
|
||||
GET /api/scoreline/schools
|
||||
GET /api/scoreline/majors
|
||||
GET /api/scoreline/records
|
||||
GET /api/scoreline/trend
|
||||
GET /api/scoreline/years
|
||||
|
||||
video:
|
||||
GET /api/questions/{questionId}/videos
|
||||
GET /api/questions/videos?questionId=...
|
||||
POST /api/questions/videos/batch
|
||||
GET /api/videos/search
|
||||
|
||||
tenant-content:
|
||||
POST /api/tenant-content/questions
|
||||
PATCH /api/tenant-content/questions
|
||||
GET /api/tenant-content/assets
|
||||
PUT /api/tenant-content/assets
|
||||
POST /api/tenant-content/assets/sign-upload
|
||||
POST /api/tenant-content/assets/sign-download
|
||||
POST /api/tenant-content/imports/preview/questions
|
||||
POST /api/tenant-content/imports/questions
|
||||
GET /api/tenant-content/imports
|
||||
GET /api/tenant-content/imports/issues
|
||||
GET /api/tenant-content/videos
|
||||
PUT /api/tenant-content/videos
|
||||
POST /api/tenant-content/question-videos
|
||||
GET /api/tenant-content/scoreline/schools
|
||||
PUT /api/tenant-content/scoreline/schools
|
||||
GET /api/tenant-content/scoreline/majors
|
||||
PUT /api/tenant-content/scoreline/majors
|
||||
GET /api/tenant-content/scoreline/fields
|
||||
PUT /api/tenant-content/scoreline/fields
|
||||
GET /api/tenant-content/scoreline/records
|
||||
PUT /api/tenant-content/scoreline/records
|
||||
GET /api/tenant-content/vocabulary-units
|
||||
PUT /api/tenant-content/vocabulary-units
|
||||
GET /api/tenant-content/vocabulary-words
|
||||
PUT /api/tenant-content/vocabulary-words
|
||||
GET /api/tenant-content/handbook-subjects
|
||||
PUT /api/tenant-content/handbook-subjects
|
||||
GET /api/tenant-content/handbook-chapters
|
||||
PUT /api/tenant-content/handbook-chapters
|
||||
GET /api/tenant-content/handbook-entries
|
||||
PUT /api/tenant-content/handbook-entries
|
||||
|
||||
commerce:
|
||||
POST /api/commerce/orders
|
||||
GET /api/commerce/orders
|
||||
GET /api/commerce/entitlements
|
||||
GET /api/commerce/entitlements/check
|
||||
POST /api/commerce/payments/manual-confirm
|
||||
POST /api/commerce/activation-codes/redeem
|
||||
|
||||
referral/crm:
|
||||
POST /api/referral/invite-code
|
||||
POST /api/referral/resolve
|
||||
POST /api/referral/track-event
|
||||
POST /api/referral/bind
|
||||
GET /api/referral/stats
|
||||
GET /api/referral/sales-stats
|
||||
GET /api/referral/sales-clients
|
||||
POST /api/referral/manual-bind
|
||||
GET /api/referral/team
|
||||
PUT /api/referral/team
|
||||
POST /api/referral/qrcode
|
||||
GET /api/crm/config
|
||||
PUT /api/crm/config
|
||||
GET /api/crm/queue
|
||||
|
||||
tenant-admin:
|
||||
GET /api/tenant-admin/permissions
|
||||
GET /api/tenant-admin/overview
|
||||
PUT /api/tenant-admin/branding
|
||||
PUT /api/tenant-admin/settings
|
||||
GET /api/tenant-admin/domains
|
||||
POST /api/tenant-admin/domains
|
||||
GET /api/tenant-admin/payment-accounts
|
||||
PUT /api/tenant-admin/payment-accounts
|
||||
GET /api/tenant-admin/auth-providers
|
||||
PUT /api/tenant-admin/auth-providers
|
||||
GET /api/tenant-admin/secrets
|
||||
PUT /api/tenant-admin/secrets
|
||||
GET /api/tenant-admin/banners
|
||||
PUT /api/tenant-admin/banners
|
||||
GET /api/tenant-admin/faqs
|
||||
PUT /api/tenant-admin/faqs
|
||||
GET /api/tenant-admin/announcements
|
||||
PUT /api/tenant-admin/announcements
|
||||
GET /api/tenant-admin/code-batches
|
||||
PUT /api/tenant-admin/code-batches
|
||||
GET /api/tenant-admin/activation-codes
|
||||
PUT /api/tenant-admin/activation-codes
|
||||
POST /api/tenant-admin/activation-codes/generate
|
||||
GET /api/tenant-admin/coupons
|
||||
PUT /api/tenant-admin/coupons
|
||||
GET /api/tenant-admin/members
|
||||
PUT /api/tenant-admin/members
|
||||
POST /api/tenant-admin/members/disable
|
||||
GET /api/tenant-admin/audit-logs
|
||||
|
||||
platform-admin:
|
||||
GET /api/platform-admin/overview
|
||||
GET /api/platform-admin/plans
|
||||
GET /api/platform-admin/tenants
|
||||
POST /api/platform-admin/tenants
|
||||
GET /api/platform-admin/tenants/detail
|
||||
PATCH /api/platform-admin/tenants/status
|
||||
PUT /api/platform-admin/tenants/billing-profile
|
||||
POST /api/platform-admin/subscriptions
|
||||
GET /api/platform-admin/invoices
|
||||
POST /api/platform-admin/invoices
|
||||
POST /api/platform-admin/invoices/from-subscription
|
||||
POST /api/platform-admin/invoices/payments/manual-confirm
|
||||
GET /api/platform-admin/usage
|
||||
POST /api/platform-admin/usage
|
||||
```
|
||||
|
||||
## 商用交付缺口
|
||||
|
||||
上线前至少还需要完成:
|
||||
|
||||
1. 正式鉴权:迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session,并逐表验证 RLS。
|
||||
2. 国内能力接入:短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
|
||||
3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。
|
||||
4. 后台能力:题库录入、JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、真实对象存储签名和前端操作台待补。
|
||||
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。
|
||||
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
||||
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
|
||||
|
||||
## 下一步优先级
|
||||
|
||||
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
||||
|
||||
1. 完善内容导入和文件上传:Excel/CSV、单词、手册、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名。
|
||||
2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。
|
||||
3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。
|
||||
4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
|
||||
5. 补 AI 择校推荐报告、排行榜、勋章自动发放。
|
||||
6. 接真实支付、短信、微信/QQ 登录 provider adapter,并开始 Taro scaffold。
|
||||
127
docs/refactor/local-supabase.md
Normal file
127
docs/refactor/local-supabase.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 本地 Supabase 开发环境
|
||||
|
||||
## 前置依赖
|
||||
|
||||
Supabase 本地开发需要:
|
||||
|
||||
- Docker Desktop
|
||||
- Supabase CLI
|
||||
- Node.js 20+
|
||||
|
||||
当前机器如果出现下面错误,说明 Docker Desktop 未启动或 Docker daemon 不可访问:
|
||||
|
||||
```text
|
||||
failed to inspect container health
|
||||
open //./pipe/docker_engine: The system cannot find the file specified
|
||||
```
|
||||
|
||||
先启动 Docker Desktop,再执行 Supabase 命令。
|
||||
|
||||
当前仓库已经加入 `supabase/config.toml` 和第一版 migration。安装依赖后执行:
|
||||
|
||||
```bash
|
||||
npm run supabase:start
|
||||
npm run supabase:status
|
||||
```
|
||||
|
||||
本地默认端口:
|
||||
|
||||
```text
|
||||
API: http://127.0.0.1:54321
|
||||
DB: postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
Studio: http://127.0.0.1:54323
|
||||
Inbucket: http://127.0.0.1:54324
|
||||
```
|
||||
|
||||
重置数据库:
|
||||
|
||||
```bash
|
||||
npm run supabase:reset
|
||||
npm run db:smoke-seed
|
||||
```
|
||||
|
||||
## API 服务
|
||||
|
||||
```bash
|
||||
npm run dev:api
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/health
|
||||
```
|
||||
|
||||
租户解析:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:8787/api/tenant/resolve?host=localhost"
|
||||
curl "http://127.0.0.1:8787/api/tenant/resolve?tenantCode=master"
|
||||
```
|
||||
|
||||
公开题库数据接口示例:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:8787/api/catalog/regions?tenantId=00000000-0000-0000-0000-000000000001"
|
||||
curl "http://127.0.0.1:8787/api/catalog/questions?tenantId=00000000-0000-0000-0000-000000000001&limit=20"
|
||||
```
|
||||
|
||||
本地短信登录 smoke 可用 mock provider。开发环境接口会返回 `debugCode`:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8787/api/auth/sms/send" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-tenant-id: 00000000-0000-0000-0000-000000000001" \
|
||||
-d "{\"phone\":\"13900000001\",\"purpose\":\"login\"}"
|
||||
```
|
||||
|
||||
平台运营接口迁移期使用 `x-platform-admin-key`。本地默认值来自 `PLATFORM_ADMIN_API_KEY`,未设置时为 `local-platform-admin-key`:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:8787/api/platform-admin/overview" \
|
||||
-H "x-platform-admin-key: local-platform-admin-key"
|
||||
|
||||
curl "http://127.0.0.1:8787/api/platform-admin/tenants?limit=20" \
|
||||
-H "x-platform-admin-key: local-platform-admin-key"
|
||||
```
|
||||
|
||||
## Docker 运行 API
|
||||
|
||||
本地 Supabase 继续由 Supabase CLI 启动,API 可以单独进入 Docker 容器:
|
||||
|
||||
```bash
|
||||
npm run supabase:start
|
||||
npm run docker:api:build
|
||||
npm run docker:api:up
|
||||
```
|
||||
|
||||
Compose 文件是 `docker-compose.api.yml`。API 容器默认使用:
|
||||
|
||||
```text
|
||||
DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:54322/postgres
|
||||
PORT=8787
|
||||
```
|
||||
|
||||
如果本机已有非容器 API 占用 `8787`,先关闭旧进程或临时修改 `docker-compose.api.yml` 的端口映射。当前机器 Docker Desktop 可用,Supabase 容器已可运行;若构建 API 镜像时报 Docker Hub 或 ECR 拉取超时,需要先处理 Docker 镜像源或网络代理。
|
||||
|
||||
## PocketBase JSON 导入
|
||||
|
||||
把 PocketBase 导出的集合 JSON 放到仓库根目录的 `pb_export` 文件夹后执行:
|
||||
|
||||
```bash
|
||||
npm run pb:import:json
|
||||
npm run pb:import:validate
|
||||
```
|
||||
|
||||
导入真实密钥时必须明确打开开关,且只允许进入 `app_private.tenant_secrets`:
|
||||
|
||||
```bash
|
||||
set IMPORT_SECRET_VALUES=true
|
||||
npm run pb:import:json
|
||||
```
|
||||
|
||||
上线前 `pb:import:validate` 不能有 `FAIL`。`WARN` 通常代表旧数据缺失关联,需要业务确认后记录处理结论。
|
||||
|
||||
## 说明
|
||||
|
||||
Supabase Custom Domain 不用于合作商多域名绑定。合作商域名在你们自己的 Web/API 网关层解析,然后从 `tenant_domains` 表得到 `tenant_id`。
|
||||
89
docs/refactor/pocketbase-to-supabase-mapping.md
Normal file
89
docs/refactor/pocketbase-to-supabase-mapping.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# PocketBase 到 Supabase 迁移映射
|
||||
|
||||
## 迁移原则
|
||||
|
||||
- 保留 PocketBase 旧 ID 到 `legacy_id`,新系统主键统一用 UUID。
|
||||
- 所有租户内业务表都带 `tenant_id`。
|
||||
- 旧表原始 JSON 进入 `pb_raw_records` 前默认脱敏,保证可追溯但不泄露密钥。
|
||||
- 旧集合里不符合商用规范的字段不得直接映射到正式表,必须经过清洗和拆表。
|
||||
- 第一阶段标准化导入覆盖核心商用链路:租户、用户、题库目录、题目、订单、支付、权益、激活码、优惠券、词库、手册、运营内容、分数线、视频解析、CRM 和推广关系。未覆盖集合先 raw import,并在 `pb_import_issues` 里记录风险。
|
||||
- `users.isSvip`、`svipExpiry`、`svipRegions` 最终迁移为 `entitlements`。
|
||||
- `users.stats.favorites`、`wrongBook` 最终迁移为 `favorite_questions`、`wrong_questions`。
|
||||
- `settings` 中的密钥类配置必须进入 `app_private.tenant_secrets` 或外部 Vault,不进入 `public` schema。
|
||||
|
||||
## 核心映射
|
||||
|
||||
| PocketBase | PostgreSQL | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `tenant_config` | `tenants` / `tenant_branding` / `tenant_settings` | 从单实例配置升级为平台租户配置 |
|
||||
| `users` | `platform_users` / `tenant_memberships` / `student_profiles` / `user_identities` | 用户身份、租户角色、学生资料拆分 |
|
||||
| `regions` | `regions` | 增加 `tenant_id` |
|
||||
| `region_modules` | `region_modules` | 增加 `tenant_id` |
|
||||
| `module_nodes` | `module_nodes` | 作为后续题库层级主结构 |
|
||||
| `subjects` | `subjects` | 保留旧结构兼容,逐步和 `module_nodes` 对齐 |
|
||||
| `categories` | `categories` | 保留旧结构兼容 |
|
||||
| `questions` | `questions` / `question_versions` | 题目实体和题目内容版本拆分 |
|
||||
| `orders` | `orders` / `order_items` / `payments` / `payment_events` | 订单与支付流水分离 |
|
||||
| `svip_plans` | `svip_plans` | 金额改为分 |
|
||||
| `codes` | `activation_codes` | 激活码表 |
|
||||
| `code_batches` | `code_batches` | 批次表 |
|
||||
| `coupons` | `coupons` | 优惠券 |
|
||||
| `coupon_redemptions` | `coupon_redemptions` | 兑换流水 |
|
||||
| `vocabulary_units` | `vocabulary_units` | 背单词单元 |
|
||||
| `vocabulary` | `vocabulary_words` | 单词表 |
|
||||
| `handbook_*` | `handbook_*` | 手册内容 |
|
||||
| `banners` / `faqs` / `announcements` | 同名表 | 运营内容 |
|
||||
| `settings` | `tenant_settings` / `tenant_payment_accounts` | 拆分公开配置、私密配置、支付配置 |
|
||||
| `crm_config.secret` | `app_private.tenant_secrets` | 公共表只保留 `secret_ref` |
|
||||
| `users.stats.favorites` | `favorite_questions` | 迁移为关系表 |
|
||||
| `users.stats.wrongBook` | `wrong_questions` | 迁移为关系表 |
|
||||
| `users.isSvip` / `svipExpiry` / `svipRegions` | `entitlements` | 迁移为租户/地区范围权益 |
|
||||
| `user_word_progress` / `user_word_favorites` | 同名规范表 | 关联到 `platform_users` 与 `vocabulary_words` |
|
||||
| `app_assets` / `images` | `content_assets` | 统一素材索引 |
|
||||
|
||||
## 导入命令
|
||||
|
||||
先安装导入器依赖:
|
||||
|
||||
```bash
|
||||
cd scripts/import-pocketbase
|
||||
copy .env.example .env
|
||||
npm install
|
||||
```
|
||||
|
||||
查看 schema 摘要:
|
||||
|
||||
```bash
|
||||
npm run schema:summary
|
||||
npm run schema:risk
|
||||
```
|
||||
|
||||
把 PocketBase 导出的集合 JSON 放到 `pb_export`:
|
||||
|
||||
```text
|
||||
pb_export/
|
||||
users.json
|
||||
regions.json
|
||||
questions.json
|
||||
```
|
||||
|
||||
执行导入:
|
||||
|
||||
```bash
|
||||
npm run import:json
|
||||
```
|
||||
|
||||
导入后执行验证:
|
||||
|
||||
```bash
|
||||
npm run import:validate
|
||||
```
|
||||
|
||||
根目录也可以执行:
|
||||
|
||||
```bash
|
||||
npm run pb:import:json
|
||||
npm run pb:import:validate
|
||||
```
|
||||
|
||||
导入器会先把所有 JSON 放入 `pb_raw_records`,并默认对敏感字段脱敏;然后按依赖顺序标准化导入业务表。上线前必须处理 `FAIL` 项;`WARN` 项通常表示旧数据关系缺失,例如旧题目引用了不存在的章节,需要业务确认是否可接受。
|
||||
5350
package-lock.json
generated
Normal file
5350
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
package.json
Normal file
80
package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "tianjin-zsb-master",
|
||||
"version": "1.0.0",
|
||||
"description": "升本刷题通 - 专业的专升本备考刷题软件",
|
||||
"author": "升本刷题通团队",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"homepage": "./",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
"scripts/import-pocketbase"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/genai": "*",
|
||||
"codemirror": "^6.0.2",
|
||||
"docx": "^9.6.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.330.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"perfect-freehand": "^1.2.3",
|
||||
"pocketbase": "^0.21.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-to-print": "^3.3.0",
|
||||
"recharts": "^2.12.7",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"supabase": "^2.107.0",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --mode public",
|
||||
"dev:admin": "vite --mode admin",
|
||||
"dev:api": "npm --workspace @tiku-saas/api run dev",
|
||||
"build:api": "npm --workspace @tiku-saas/api run build",
|
||||
"check:api": "npm --workspace @tiku-saas/api run check",
|
||||
"check:importer": "npm --workspace @tiku-saas/import-pocketbase run check",
|
||||
"check:refactor": "npm run check:api && npm run check:importer && npm run pb:import:validate && npm run test:api",
|
||||
"docker:api:build": "docker compose -f docker-compose.api.yml build",
|
||||
"docker:api:up": "docker compose -f docker-compose.api.yml up api",
|
||||
"docker:api:down": "docker compose -f docker-compose.api.yml down",
|
||||
"supabase:start": "supabase start",
|
||||
"supabase:stop": "supabase stop",
|
||||
"supabase:status": "supabase status",
|
||||
"supabase:reset": "supabase db reset",
|
||||
"db:smoke-seed": "node scripts/smoke-seed.js",
|
||||
"smoke:core-api": "node scripts/smoke-core-api.js",
|
||||
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
||||
"test:api:remote": "node scripts/api-integration-test.js",
|
||||
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
|
||||
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
|
||||
"pb:import:json": "npm --workspace @tiku-saas/import-pocketbase run import:json",
|
||||
"pb:import:validate": "npm --workspace @tiku-saas/import-pocketbase run import:validate",
|
||||
"build": "npm run build:public",
|
||||
"build:public": "tsc && vite build --mode public",
|
||||
"build:admin": "tsc && vite build --mode admin",
|
||||
"build:all": "npm run build:public && npm run build:admin",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
12
packages/config/package-lock.json
generated
Normal file
12
packages/config/package-lock.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@tiku-saas/config",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/config",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/config/package.json
Normal file
8
packages/config/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@tiku-saas/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
43
packages/config/src/index.js
Normal file
43
packages/config/src/index.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
export const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
|
||||
export const DEFAULT_TENANT_SLUG = 'master';
|
||||
export const DEFAULT_TENANT_NAME = '升本刷题通主租户';
|
||||
export function loadDotenv(cwd = process.cwd()) {
|
||||
const envPath = path.resolve(cwd, '.env');
|
||||
if (!fs.existsSync(envPath))
|
||||
return;
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx === -1)
|
||||
continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim().replace(/^"|"$/g, '');
|
||||
if (!process.env[key])
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
export function envString(key, fallback) {
|
||||
return process.env[key] || fallback;
|
||||
}
|
||||
export function envNumber(key, fallback) {
|
||||
const value = Number(process.env[key]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
export function envBoolean(key, fallback = false) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined)
|
||||
return fallback;
|
||||
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
export function envList(key, fallback = '') {
|
||||
return (process.env[key] || fallback)
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
45
packages/config/src/index.ts
Normal file
45
packages/config/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
export const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
|
||||
export const DEFAULT_TENANT_SLUG = 'master';
|
||||
export const DEFAULT_TENANT_NAME = '升本刷题通主租户';
|
||||
|
||||
export function loadDotenv(cwd = process.cwd()) {
|
||||
const envPath = path.resolve(cwd, '.env');
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim().replace(/^"|"$/g, '');
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function envString(key: string, fallback: string) {
|
||||
return process.env[key] || fallback;
|
||||
}
|
||||
|
||||
export function envNumber(key: string, fallback: number) {
|
||||
const value = Number(process.env[key]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function envBoolean(key: string, fallback = false) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined) return fallback;
|
||||
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
export function envList(key: string, fallback = '') {
|
||||
return (process.env[key] || fallback)
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
194
packages/db/package-lock.json
generated
Normal file
194
packages/db/package-lock.json
generated
Normal file
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"name": "@tiku-saas/db",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/db",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
|
||||
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/db/package.json
Normal file
14
packages/db/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@tiku-saas/db",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4"
|
||||
}
|
||||
}
|
||||
18
packages/db/src/index.js
Normal file
18
packages/db/src/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import pg from 'pg';
|
||||
import { DEFAULT_DATABASE_URL } from '../../config/src/index.js';
|
||||
const { Pool } = pg;
|
||||
export function createPool(options = {}) {
|
||||
return new Pool({
|
||||
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
max: options.max || 10,
|
||||
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
|
||||
});
|
||||
}
|
||||
export async function query(pool, sql, params = []) {
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
export async function queryOne(pool, sql, params = []) {
|
||||
const rows = await query(pool, sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
28
packages/db/src/index.ts
Normal file
28
packages/db/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import pg from 'pg';
|
||||
import { DEFAULT_DATABASE_URL } from '../../config/src/index.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export interface DbPoolOptions {
|
||||
connectionString?: string;
|
||||
max?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
}
|
||||
|
||||
export function createPool(options: DbPoolOptions = {}) {
|
||||
return new Pool({
|
||||
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
max: options.max || 10,
|
||||
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function query<T = unknown>(pool: pg.Pool, sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows as T[];
|
||||
}
|
||||
|
||||
export async function queryOne<T = unknown>(pool: pg.Pool, sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<T>(pool, sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
12
packages/domain/package-lock.json
generated
Normal file
12
packages/domain/package-lock.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@tiku-saas/domain",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/domain",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/domain/package.json
Normal file
8
packages/domain/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@tiku-saas/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
37
packages/domain/src/index.js
Normal file
37
packages/domain/src/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export const TENANT_ROLES = [
|
||||
'platform_admin',
|
||||
'tenant_owner',
|
||||
'tenant_admin',
|
||||
'tenant_operator',
|
||||
'teacher',
|
||||
'sales',
|
||||
'agent',
|
||||
'student',
|
||||
];
|
||||
export const QUESTION_STATUSES = ['draft', 'published', 'archived'];
|
||||
export const ORDER_STATUSES = ['pending', 'paid', 'failed', 'closed', 'refunded'];
|
||||
export const ENTITLEMENT_SCOPE_TYPES = ['tenant', 'region', 'module', 'subject', 'question_bank'];
|
||||
export function normalizeTenantRole(role) {
|
||||
const value = String(role || '').toLowerCase();
|
||||
if (value === 'superadmin')
|
||||
return 'platform_admin';
|
||||
if (value === 'admin')
|
||||
return 'tenant_admin';
|
||||
if (value === 'operator')
|
||||
return 'tenant_operator';
|
||||
if (TENANT_ROLES.includes(value))
|
||||
return value;
|
||||
return 'student';
|
||||
}
|
||||
export function normalizeOrderStatus(status) {
|
||||
const value = String(status || '').toLowerCase();
|
||||
if (value === 'paid' || value === 'success')
|
||||
return 'paid';
|
||||
if (value === 'failed' || value === 'fail')
|
||||
return 'failed';
|
||||
if (value === 'refunded' || value === 'refund')
|
||||
return 'refunded';
|
||||
if (value === 'closed' || value === 'cancelled' || value === 'canceled')
|
||||
return 'closed';
|
||||
return 'pending';
|
||||
}
|
||||
39
packages/domain/src/index.ts
Normal file
39
packages/domain/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const TENANT_ROLES = [
|
||||
'platform_admin',
|
||||
'tenant_owner',
|
||||
'tenant_admin',
|
||||
'tenant_operator',
|
||||
'teacher',
|
||||
'sales',
|
||||
'agent',
|
||||
'student',
|
||||
] as const;
|
||||
|
||||
export type TenantRole = (typeof TENANT_ROLES)[number];
|
||||
|
||||
export const QUESTION_STATUSES = ['draft', 'published', 'archived'] as const;
|
||||
export type QuestionStatus = (typeof QUESTION_STATUSES)[number];
|
||||
|
||||
export const ORDER_STATUSES = ['pending', 'paid', 'failed', 'closed', 'refunded'] as const;
|
||||
export type OrderStatus = (typeof ORDER_STATUSES)[number];
|
||||
|
||||
export const ENTITLEMENT_SCOPE_TYPES = ['tenant', 'region', 'module', 'subject', 'question_bank'] as const;
|
||||
export type EntitlementScopeType = (typeof ENTITLEMENT_SCOPE_TYPES)[number];
|
||||
|
||||
export function normalizeTenantRole(role: unknown): TenantRole {
|
||||
const value = String(role || '').toLowerCase();
|
||||
if (value === 'superadmin') return 'platform_admin';
|
||||
if (value === 'admin') return 'tenant_admin';
|
||||
if (value === 'operator') return 'tenant_operator';
|
||||
if (TENANT_ROLES.includes(value as TenantRole)) return value as TenantRole;
|
||||
return 'student';
|
||||
}
|
||||
|
||||
export function normalizeOrderStatus(status: unknown): OrderStatus {
|
||||
const value = String(status || '').toLowerCase();
|
||||
if (value === 'paid' || value === 'success') return 'paid';
|
||||
if (value === 'failed' || value === 'fail') return 'failed';
|
||||
if (value === 'refunded' || value === 'refund') return 'refunded';
|
||||
if (value === 'closed' || value === 'cancelled' || value === 'canceled') return 'closed';
|
||||
return 'pending';
|
||||
}
|
||||
1138
scripts/api-integration-test.js
Normal file
1138
scripts/api-integration-test.js
Normal file
File diff suppressed because it is too large
Load Diff
4
scripts/import-pocketbase/.env.example
Normal file
4
scripts/import-pocketbase/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
TENANT_ID=00000000-0000-0000-0000-000000000001
|
||||
PB_SCHEMA_PATH=../../docs/pb_schema.json
|
||||
PB_EXPORT_DIR=../../pb_export
|
||||
729
scripts/import-pocketbase/package-lock.json
generated
Normal file
729
scripts/import-pocketbase/package-lock.json
generated
Normal file
@@ -0,0 +1,729 @@
|
||||
{
|
||||
"name": "@tiku-saas/import-pocketbase",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/import-pocketbase",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
scripts/import-pocketbase/package.json
Normal file
22
scripts/import-pocketbase/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@tiku-saas/import-pocketbase",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"schema:summary": "tsx src/analyze-schema.ts",
|
||||
"schema:risk": "tsx src/risk-report.ts",
|
||||
"import:json": "tsx src/import-json.ts",
|
||||
"import:validate": "tsx src/validate-import.ts",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
30
scripts/import-pocketbase/src/analyze-schema.ts
Normal file
30
scripts/import-pocketbase/src/analyze-schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { readPocketBaseSchema, fieldsOf } from './pb-schema.js';
|
||||
|
||||
const { absPath, collections, byId } = readPocketBaseSchema();
|
||||
|
||||
const businessCollections = collections.filter(c => !c.name.startsWith('_'));
|
||||
const authCollections = collections.filter(c => c.type === 'auth');
|
||||
const relationCount = collections.reduce(
|
||||
(count, collection) => count + fieldsOf(collection).filter(field => field.type === 'relation').length,
|
||||
0,
|
||||
);
|
||||
|
||||
console.log(`PocketBase schema: ${absPath}`);
|
||||
console.log(`Collections: ${collections.length}`);
|
||||
console.log(`Business collections: ${businessCollections.length}`);
|
||||
console.log(`Auth collections: ${authCollections.map(c => c.name).join(', ') || 'none'}`);
|
||||
console.log(`Relation fields: ${relationCount}`);
|
||||
console.log('');
|
||||
|
||||
for (const collection of businessCollections.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const fields = fieldsOf(collection);
|
||||
const relationFields = fields.filter(field => field.type === 'relation');
|
||||
const relationSummary = relationFields
|
||||
.map(field => {
|
||||
const target = field.collectionId ? byId.get(field.collectionId)?.name || field.collectionId : '?';
|
||||
return `${field.name}->${target}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
console.log(`${collection.name.padEnd(24)} ${collection.type.padEnd(6)} fields=${String(fields.length).padStart(2)} relations=${relationSummary || '-'}`);
|
||||
}
|
||||
22
scripts/import-pocketbase/src/db.ts
Normal file
22
scripts/import-pocketbase/src/db.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { DEFAULT_DATABASE_URL } from '../../../packages/config/src/index.js';
|
||||
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../packages/db/src/index.js';
|
||||
import { loadEnv } from './env.js';
|
||||
|
||||
loadEnv();
|
||||
|
||||
export const pool = createPool({
|
||||
connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
max: 5,
|
||||
});
|
||||
|
||||
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
return runQuery<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
return runQueryOne<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function closeDb() {
|
||||
await pool.end();
|
||||
}
|
||||
5
scripts/import-pocketbase/src/env.ts
Normal file
5
scripts/import-pocketbase/src/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { loadDotenv } from '../../../packages/config/src/index.js';
|
||||
|
||||
export function loadEnv() {
|
||||
loadDotenv();
|
||||
}
|
||||
2926
scripts/import-pocketbase/src/import-json.ts
Normal file
2926
scripts/import-pocketbase/src/import-json.ts
Normal file
File diff suppressed because it is too large
Load Diff
38
scripts/import-pocketbase/src/pb-schema.ts
Normal file
38
scripts/import-pocketbase/src/pb-schema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface PocketBaseField {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
values?: string[];
|
||||
collectionId?: string;
|
||||
maxSelect?: number;
|
||||
}
|
||||
|
||||
export interface PocketBaseCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
fields?: PocketBaseField[];
|
||||
schema?: PocketBaseField[];
|
||||
listRule?: string;
|
||||
viewRule?: string;
|
||||
createRule?: string;
|
||||
updateRule?: string;
|
||||
deleteRule?: string;
|
||||
}
|
||||
|
||||
export function readPocketBaseSchema(schemaPath = process.env.PB_SCHEMA_PATH || '../../docs/pb_schema.json') {
|
||||
const absPath = path.resolve(process.cwd(), schemaPath);
|
||||
const raw = JSON.parse(fs.readFileSync(absPath, 'utf8'));
|
||||
const collections = (Array.isArray(raw) ? raw : raw.collections || []) as PocketBaseCollection[];
|
||||
const byId = new Map(collections.map(c => [c.id, c]));
|
||||
const byName = new Map(collections.map(c => [c.name, c]));
|
||||
return { absPath, collections, byId, byName };
|
||||
}
|
||||
|
||||
export function fieldsOf(collection: PocketBaseCollection) {
|
||||
return collection.fields || collection.schema || [];
|
||||
}
|
||||
80
scripts/import-pocketbase/src/risk-report.ts
Normal file
80
scripts/import-pocketbase/src/risk-report.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { readPocketBaseSchema, fieldsOf } from './pb-schema.js';
|
||||
|
||||
const { collections, byId, absPath } = readPocketBaseSchema();
|
||||
|
||||
const sensitivePattern = /(password|token|secret|private|session|openid|unionid|accesskey|appkey|apikey|apiV3Key|notifyToken|aesKey|mchId|appSecret)/i;
|
||||
const likelySafeKeyPattern = /(^key$|fieldKey|tokenKey)$/i;
|
||||
const jsonRiskPattern = /(stats|progress|config|settings|raw|payload|metadata|fieldValues|svipRegions|recentActivities)/i;
|
||||
|
||||
interface Risk {
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
collection: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const risks: Risk[] = [];
|
||||
|
||||
for (const collection of collections.filter(c => !c.name.startsWith('_'))) {
|
||||
const fields = fieldsOf(collection);
|
||||
|
||||
if (collection.name === 'settings') {
|
||||
risks.push({
|
||||
severity: 'critical',
|
||||
collection: collection.name,
|
||||
message: 'Monolithic settings table mixes public config and secrets. Split into tenant_settings, tenant_payment_accounts and app_private.tenant_secrets.',
|
||||
});
|
||||
}
|
||||
|
||||
if (collection.name === 'users') {
|
||||
risks.push({
|
||||
severity: 'high',
|
||||
collection: collection.name,
|
||||
message: 'Legacy users table mixes auth, profile, roles, learning stats, membership and referral data. Split before migration.',
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
if ((sensitivePattern.test(field.name) && !likelySafeKeyPattern.test(field.name)) || field.hidden) {
|
||||
risks.push({
|
||||
severity: field.hidden || sensitivePattern.test(field.name) ? 'critical' : 'high',
|
||||
collection: collection.name,
|
||||
field: field.name,
|
||||
message: `Sensitive or hidden field "${field.name}" must not be copied into public normalized tables.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.type === 'json' && jsonRiskPattern.test(field.name)) {
|
||||
risks.push({
|
||||
severity: 'medium',
|
||||
collection: collection.name,
|
||||
field: field.name,
|
||||
message: `JSON field "${field.name}" needs explicit normalization or a documented reason to remain JSONB.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.type === 'relation' && field.collectionId) {
|
||||
const target = byId.get(field.collectionId);
|
||||
if (!target) {
|
||||
risks.push({
|
||||
severity: 'high',
|
||||
collection: collection.name,
|
||||
field: field.name,
|
||||
message: `Relation points to missing collection id "${field.collectionId}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const order: Record<Risk['severity'], number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
risks.sort((a, b) => order[a.severity] - order[b.severity] || a.collection.localeCompare(b.collection));
|
||||
|
||||
console.log(`PocketBase schema risk report: ${absPath}`);
|
||||
console.log(`Risks: ${risks.length}`);
|
||||
console.log('');
|
||||
|
||||
for (const risk of risks) {
|
||||
const location = risk.field ? `${risk.collection}.${risk.field}` : risk.collection;
|
||||
console.log(`[${risk.severity.toUpperCase()}] ${location} - ${risk.message}`);
|
||||
}
|
||||
354
scripts/import-pocketbase/src/validate-import.ts
Normal file
354
scripts/import-pocketbase/src/validate-import.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { closeDb, query } from './db.js';
|
||||
import { loadEnv } from './env.js';
|
||||
|
||||
loadEnv();
|
||||
|
||||
type CheckStatus = 'pass' | 'warn' | 'fail';
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
status: CheckStatus;
|
||||
count?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
const failOnWarnings = process.env.FAIL_ON_WARNINGS === 'true';
|
||||
|
||||
async function scalar(sql: string, params: unknown[] = []) {
|
||||
const rows = await query<{ count: string }>(sql, params);
|
||||
return Number(rows[0]?.count || 0);
|
||||
}
|
||||
|
||||
function result(name: string, count: number, failMessage: string, passMessage: string, warnOnly = false): CheckResult {
|
||||
if (count > 0) {
|
||||
return {
|
||||
name,
|
||||
status: warnOnly ? 'warn' : 'fail',
|
||||
count,
|
||||
message: failMessage,
|
||||
};
|
||||
}
|
||||
return { name, status: 'pass', count, message: passMessage };
|
||||
}
|
||||
|
||||
async function tableCounts(): Promise<CheckResult[]> {
|
||||
const tables = [
|
||||
'platform_users',
|
||||
'regions',
|
||||
'region_modules',
|
||||
'module_nodes',
|
||||
'schools',
|
||||
'majors',
|
||||
'subjects',
|
||||
'categories',
|
||||
'questions',
|
||||
'orders',
|
||||
'svip_plans',
|
||||
'activation_codes',
|
||||
'vocabulary_units',
|
||||
'vocabulary_words',
|
||||
'handbook_subjects',
|
||||
'handbook_chapters',
|
||||
'handbook_entries',
|
||||
'products',
|
||||
];
|
||||
|
||||
const checks: CheckResult[] = [];
|
||||
for (const table of tables) {
|
||||
const count =
|
||||
table === 'platform_users'
|
||||
? await scalar('select count(*) from public.platform_users')
|
||||
: await scalar(`select count(*) from public.${table} where tenant_id = $1`, [tenantId]);
|
||||
checks.push({
|
||||
name: `count:${table}`,
|
||||
status: 'pass',
|
||||
count,
|
||||
message: `${table} rows: ${count}`,
|
||||
});
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
async function validationChecks(): Promise<CheckResult[]> {
|
||||
const checks: CheckResult[] = [];
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'tenant_exists',
|
||||
(await scalar('select count(*) from public.tenants where id = $1', [tenantId])) === 0 ? 1 : 0,
|
||||
'Tenant seed is missing. Run Supabase seed or set TENANT_ID to an existing tenant.',
|
||||
'Tenant exists.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'raw_records_not_normalized',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.pb_raw_records r
|
||||
join public.pb_import_runs run on run.id = r.run_id
|
||||
where r.tenant_id = $1
|
||||
and run.id = (
|
||||
select id from public.pb_import_runs
|
||||
where tenant_id = $1
|
||||
order by started_at desc
|
||||
limit 1
|
||||
)
|
||||
and r.collection_name in (
|
||||
'regions','region_modules','module_nodes','schools','majors','subjects',
|
||||
'categories','questions','users','orders','svip_plans','codes',
|
||||
'vocabulary_units','vocabulary','handbook_subjects','handbook_chapters',
|
||||
'handbook_entries','banners','faqs','announcements'
|
||||
)
|
||||
and r.normalized = false
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some core raw records from the latest run were not normalized.',
|
||||
'All latest-run core raw records are marked normalized.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'questions_without_current_version',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions
|
||||
where tenant_id = $1 and current_version_id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Questions exist without a current version.',
|
||||
'Every question has a current version.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'question_versions_wrong_tenant',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions q
|
||||
join public.question_versions v on v.question_id = q.id
|
||||
where q.tenant_id = $1 and v.tenant_id <> q.tenant_id
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some question_versions have a different tenant_id from their question.',
|
||||
'Question version tenant_id values match their questions.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'questions_unresolved_subjects',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions
|
||||
where tenant_id = $1 and legacy_subject_id is not null and subject_id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some questions still have legacy_subject_id but no resolved subject_id.',
|
||||
'Question subject references are resolved.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'questions_unresolved_categories',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions
|
||||
where tenant_id = $1 and legacy_category_id is not null and category_id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some questions still have legacy_category_id but no resolved category_id.',
|
||||
'Question category references are resolved.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'orders_unresolved_users',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.orders
|
||||
where tenant_id = $1 and legacy_user_id is not null and user_id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some orders still have legacy_user_id but no resolved user_id.',
|
||||
'Order user references are resolved.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'orders_paid_without_payment',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.orders o
|
||||
where o.tenant_id = $1
|
||||
and o.status = 'paid'
|
||||
and not exists (
|
||||
select 1 from public.payments p
|
||||
where p.tenant_id = o.tenant_id and p.order_id = o.id
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Paid orders exist without payment rows.',
|
||||
'Paid orders have payment rows.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'entitlements_unresolved_user',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.entitlements e
|
||||
left join public.platform_users u on u.id = e.user_id
|
||||
where e.tenant_id = $1 and u.id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Entitlements exist without a valid user.',
|
||||
'Entitlements reference valid users.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'public_sensitive_profile_keys',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.platform_users
|
||||
where raw_profile::text ~* '"(password|tokenKey|secret|sessionKey|openId|unionId|wechatSessionKey|qqOpenId|wechatOpenId|wechatUnionId)"'
|
||||
`,
|
||||
),
|
||||
'Sensitive identity or credential keys remain in platform_users.raw_profile.',
|
||||
'No sensitive identity or credential keys found in platform_users.raw_profile.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'student_stats_legacy_arrays',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.student_profiles
|
||||
where tenant_id = $1 and (stats ? 'favorites' or stats ? 'wrongBook')
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'student_profiles.stats still contains favorites/wrongBook legacy arrays.',
|
||||
'student profile stats no longer contain favorites/wrongBook arrays.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'public_settings_sensitive_keys',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.tenant_settings
|
||||
where tenant_id = $1
|
||||
and public_config::text ~* '(secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey)'
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Sensitive-looking keys remain in tenant_settings.public_config.',
|
||||
'No sensitive-looking keys found in tenant public settings.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'public_crm_secret_leak',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.crm_config
|
||||
where tenant_id = $1 and secret_ref is not null and secret_ref !~ '^app_private\\.tenant_secrets:'
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'CRM config contains an unsafe secret_ref value.',
|
||||
'CRM config stores only private secret references.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'critical_import_issues',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.pb_import_issues
|
||||
where tenant_id = $1
|
||||
and severity = 'critical'
|
||||
and run_id = (
|
||||
select id from public.pb_import_runs
|
||||
where tenant_id = $1
|
||||
order by started_at desc
|
||||
limit 1
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Critical import issues exist in the latest run. Review pb_import_issues before launch.',
|
||||
'No critical import issues in the latest run.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checks = [...(await tableCounts()), ...(await validationChecks())];
|
||||
let failures = 0;
|
||||
let warnings = 0;
|
||||
|
||||
for (const check of checks) {
|
||||
const prefix = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
|
||||
console.log(`[${prefix}] ${check.name}: ${check.message}${check.count === undefined ? '' : ` (${check.count})`}`);
|
||||
if (check.status === 'fail') failures += 1;
|
||||
if (check.status === 'warn') warnings += 1;
|
||||
}
|
||||
|
||||
console.log(`Validation complete: ${failures} failures, ${warnings} warnings.`);
|
||||
|
||||
if (failures > 0 || (failOnWarnings && warnings > 0)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeDb();
|
||||
});
|
||||
14
scripts/import-pocketbase/tsconfig.json
Normal file
14
scripts/import-pocketbase/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "../.."
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
|
||||
}
|
||||
741
scripts/smoke-seed.js
Normal file
741
scripts/smoke-seed.js
Normal file
@@ -0,0 +1,741 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const ids = {
|
||||
user: '00000000-0000-0000-0000-000000000101',
|
||||
tenantAdminUser: '00000000-0000-0000-0000-000000000102',
|
||||
tenantOperatorUser: '00000000-0000-0000-0000-000000000103',
|
||||
tenantSalesUser: '00000000-0000-0000-0000-000000000104',
|
||||
tenantAgentUser: '00000000-0000-0000-0000-000000000105',
|
||||
region: '00000000-0000-0000-0000-000000000301',
|
||||
subject: '00000000-0000-0000-0000-000000000501',
|
||||
category: '00000000-0000-0000-0000-000000000601',
|
||||
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||
question: '00000000-0000-0000-0000-000000000401',
|
||||
questionVersion: '00000000-0000-0000-0000-000000000402',
|
||||
plan: '00000000-0000-0000-0000-000000000201',
|
||||
order: '00000000-0000-0000-0000-000000000701',
|
||||
payment: '00000000-0000-0000-0000-000000000702',
|
||||
activationCode: '00000000-0000-0000-0000-000000000801',
|
||||
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
|
||||
vocabularyWord: '00000000-0000-0000-0000-000000000812',
|
||||
video: '00000000-0000-0000-0000-000000000821',
|
||||
questionVideo: '00000000-0000-0000-0000-000000000822',
|
||||
scorelineSchool: '00000000-0000-0000-0000-000000000831',
|
||||
scorelineMajor: '00000000-0000-0000-0000-000000000832',
|
||||
scorelineField: '00000000-0000-0000-0000-000000000833',
|
||||
scorelineRecord: '00000000-0000-0000-0000-000000000834',
|
||||
recentPractice: '00000000-0000-0000-0000-000000000841',
|
||||
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
||||
partnerInvoice: '00000000-0000-0000-0000-000000000903',
|
||||
partnerInvoiceItem: '00000000-0000-0000-0000-000000000904',
|
||||
partnerInvoicePayment: '00000000-0000-0000-0000-000000000905',
|
||||
};
|
||||
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.crm_webhook_queue
|
||||
where tenant_id = $1
|
||||
and (
|
||||
record_id in ($2::text, $3::text, $4::text, $5::text)
|
||||
or lead_id in (
|
||||
select id::text
|
||||
from public.referral_leads
|
||||
where tenant_id = $1
|
||||
and student_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
|
||||
)
|
||||
)
|
||||
`,
|
||||
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.referral_qrcodes
|
||||
where tenant_id = $1
|
||||
and user_id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
`,
|
||||
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.referral_tracks
|
||||
where tenant_id = $1
|
||||
and (
|
||||
target_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
|
||||
or ref_user_id in ($3::uuid, $4::uuid, $5::uuid)
|
||||
)
|
||||
`,
|
||||
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.referral_leads
|
||||
where tenant_id = $1
|
||||
and student_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
|
||||
`,
|
||||
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.referral_team_edges
|
||||
where tenant_id = $1
|
||||
and (
|
||||
member_user_id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
or leader_user_id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
)
|
||||
`,
|
||||
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.tenantAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
values
|
||||
($1, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
|
||||
($2, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
|
||||
($3, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (user_id, provider, provider_subject, phone)
|
||||
values ($1, 'phone', '13800000000', '13800000000')
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
phone = excluded.phone,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
||||
values ($1, $2, 'student', 'active')
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active', updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
|
||||
values ($1, $2, 'tenant_admin', 'active', '{"content:*":true}'::jsonb)
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active',
|
||||
permissions = excluded.permissions,
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.tenantAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
||||
values ($1, $2, '{"totalAnswered":0,"correctCount":0,"wrongCount":0,"studyDays":1}'::jsonb, '{}'::jsonb)
|
||||
on conflict (tenant_id, user_id) do nothing
|
||||
`,
|
||||
[tenantId, ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
|
||||
values ($1, $2, 'smoke-region', '烟测地区', 'SMOKE', 1, true)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
code = excluded.code,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.region, tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.subjects (id, tenant_id, region_id, legacy_id, name, type, sort_order, is_active)
|
||||
values ($1, $2, $3, 'smoke-subject', '烟测科目', 'cultural', 1, true)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
region_id = excluded.region_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.subject, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.categories (id, tenant_id, subject_id, legacy_id, name, category_type, sort_order, is_active)
|
||||
values ($1, $2, $3, 'smoke-category', '烟测章节', 'chapter', 1, true)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
subject_id = excluded.subject_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.category, tenantId, ids.subject],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
|
||||
values ($1, $2, $3, '烟测题库', 'tenant', 'active', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
region_id = excluded.region_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.questionBank, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.questions (
|
||||
id, tenant_id, question_bank_id, subject_id, category_id,
|
||||
legacy_id, type, type_label, difficulty, status
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'smoke-question', 'choice', '单选题', 1, 'published')
|
||||
on conflict (id)
|
||||
do update set question_bank_id = excluded.question_bank_id,
|
||||
subject_id = excluded.subject_id,
|
||||
category_id = excluded.category_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.question, tenantId, ids.questionBank, ids.subject, ids.category],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
id, tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text, explanation
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 1, '1 + 1 = ?',
|
||||
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '2', '基础加法。'
|
||||
)
|
||||
on conflict (question_id, version_no)
|
||||
do update set content = excluded.content,
|
||||
options = excluded.options,
|
||||
correct_option_index = excluded.correct_option_index,
|
||||
correct_option_indices = excluded.correct_option_indices,
|
||||
answer_text = excluded.answer_text,
|
||||
explanation = excluded.explanation
|
||||
`,
|
||||
[ids.questionVersion, tenantId, ids.question],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, has_video_explanation = true, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, ids.question, ids.questionVersion],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.video_explanations (
|
||||
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
|
||||
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
|
||||
sort_order, is_active
|
||||
)
|
||||
values (
|
||||
$1, $2, 'smoke-video', '烟测题目视频讲解', '用于验证题目视频 API',
|
||||
'https://example.test/videos/smoke.mp4', 'https://example.test/videos/smoke.jpg',
|
||||
180, '["基础加法","烟测"]'::jsonb, true, $3, 1, 1, true
|
||||
)
|
||||
on conflict (id)
|
||||
do update set title = excluded.title,
|
||||
video_url = excluded.video_url,
|
||||
subject_id = excluded.subject_id,
|
||||
is_active = true,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.video, tenantId, ids.subject],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_videos (
|
||||
id, tenant_id, question_id, video_id, legacy_id, video_type, sort_order
|
||||
)
|
||||
values ($1, $2, $3, $4, 'smoke-question-video', 'specific', 1)
|
||||
on conflict (id)
|
||||
do update set question_id = excluded.question_id,
|
||||
video_id = excluded.video_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.questionVideo, tenantId, ids.question, ids.video],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.svip_plans (
|
||||
id, tenant_id, region_id, legacy_id, name, price_cents, original_price_cents,
|
||||
days, description, per_day_label, badge, recommended, sort_order, is_active
|
||||
)
|
||||
values ($1, $2, $3, 'smoke-plan', '烟测月卡', 990, 1990, 30, '本地 smoke 套餐', '0.33/天', 'SMOKE', true, 1, true)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
price_cents = excluded.price_cents,
|
||||
days = excluded.days,
|
||||
region_id = excluded.region_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.plan, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.orders (
|
||||
id, tenant_id, user_id, legacy_id, order_no, status, product_type, product_name,
|
||||
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, 'smoke-order', 'SMOKE-ORDER-20260621', 'pending', 'svip', '烟测月卡', 990, 'manual', 'manual', $4, 30, $5, '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
amount_cents = excluded.amount_cents,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.order, tenantId, ids.user, ids.plan, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.payments (
|
||||
id, tenant_id, order_id, provider, method, status, amount_cents, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, 'manual', 'manual', 'pending', 990, '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
amount_cents = excluded.amount_cents,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.payment, tenantId, ids.order],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.activation_codes (
|
||||
id, tenant_id, legacy_id, code, days, is_used, sale_type, unit_price_cents, used_region_id, remark
|
||||
)
|
||||
values ($1, $2, 'smoke-code', 'SMOKE20260621', 30, false, 'smoke', 0, $3, '本地 smoke 激活码')
|
||||
on conflict (id)
|
||||
do update set is_used = false,
|
||||
used_by = null,
|
||||
used_at = null,
|
||||
used_region_id = excluded.used_region_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.activationCode, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.vocabulary_units (
|
||||
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
|
||||
)
|
||||
values ($1, $2, $3, 'smoke-vocab-unit', '烟测单词单元', '本地 smoke 背单词单元', 1, 1, true)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
region_id = excluded.region_id,
|
||||
word_count = excluded.word_count,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.vocabularyUnit, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.vocabulary_words (
|
||||
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
|
||||
example, example_translation, difficulty, tags, sort_order, is_active
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'smoke-word', 'abandon', '/əˈbændən/', '放弃',
|
||||
'Do not abandon your plan.', '不要放弃你的计划。', 1,
|
||||
'["smoke","basic"]'::jsonb, 1, true
|
||||
)
|
||||
on conflict (id)
|
||||
do update set word = excluded.word,
|
||||
unit_id = excluded.unit_id,
|
||||
meaning = excluded.meaning,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.vocabularyWord, tenantId, ids.vocabularyUnit],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_word_progress (
|
||||
tenant_id, user_id, word_id, status, correct_count, wrong_count,
|
||||
last_review_date, next_review_date
|
||||
)
|
||||
values ($1, $2, $3, 'learning', 1, 0, now(), now() + interval '1 day')
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set status = excluded.status,
|
||||
correct_count = excluded.correct_count,
|
||||
wrong_count = excluded.wrong_count,
|
||||
last_review_date = excluded.last_review_date,
|
||||
next_review_date = excluded.next_review_date,
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.user, ids.vocabularyWord],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
|
||||
values ($1, $2, $3, null, now())
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.user, ids.vocabularyWord],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.scoreline_schools (
|
||||
id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order
|
||||
)
|
||||
values ($1, $2, $3, 'smoke-score-school', '烟测学院', '烟测学院', '普通', true, 1)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
region_id = excluded.region_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.scorelineSchool, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.scoreline_majors (
|
||||
id, tenant_id, region_id, school_id, legacy_id, name, sort_order
|
||||
)
|
||||
values ($1, $2, $3, $4, 'smoke-score-major', '计算机科学与技术', 1)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
school_id = excluded.school_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.scorelineMajor, tenantId, ids.region, ids.scorelineSchool],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.scoreline_fields (
|
||||
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
|
||||
unit, is_filter, is_required, is_visible, is_trend, sort_order
|
||||
)
|
||||
values ($1, $2, $3, 'smoke-score-field', 'minScore', '最低录取分', 'number', '分', true, false, true, true, 1)
|
||||
on conflict (id)
|
||||
do update set field_name = excluded.field_name,
|
||||
is_trend = excluded.is_trend,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.scorelineField, tenantId, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.scoreline_records (
|
||||
id, tenant_id, region_id, school_id, major_id, legacy_id,
|
||||
year, school_name, major_name, field_values
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, 'smoke-score-record',
|
||||
2026, '烟测学院', '计算机科学与技术',
|
||||
'{"minScore": 188, "enrollmentCount": 80}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set year = excluded.year,
|
||||
field_values = excluded.field_values,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.scorelineRecord, tenantId, ids.region, ids.scorelineSchool, ids.scorelineMajor],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.recent_practices (
|
||||
id, tenant_id, user_id, legacy_id, practice_type, target_legacy_id,
|
||||
target_name, progress, color, last_access_at, last_practice_at, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'smoke-recent-practice', 'question',
|
||||
'smoke-category', '烟测章节', 20, '#2563eb', now(), now(),
|
||||
'{"source":"smoke-seed"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set progress = excluded.progress,
|
||||
last_access_at = excluded.last_access_at,
|
||||
last_practice_at = excluded.last_practice_at,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.recentPractice, tenantId, ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values (
|
||||
$1,
|
||||
'smoke-partner',
|
||||
'烟测合作商',
|
||||
'烟测合作商有限公司',
|
||||
'active',
|
||||
'saas',
|
||||
'active',
|
||||
'{"source":"smoke-seed","contact":"partner"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set name = excluded.name,
|
||||
legal_name = excluded.legal_name,
|
||||
status = excluded.status,
|
||||
billing_status = excluded.billing_status,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
|
||||
values ($1, '烟测合作商题库', '合作商题库', '本地 SaaS 烟测租户')
|
||||
on conflict (tenant_id)
|
||||
do update set brand_name = excluded.brand_name,
|
||||
short_name = excluded.short_name,
|
||||
slogan = excluded.slogan,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values (
|
||||
$1,
|
||||
'{"enableStore":true,"enableVocabulary":true}'::jsonb,
|
||||
'{"enableQuestionCRUD":true,"enableMarketing":true}'::jsonb,
|
||||
'{"appUrl":"http://smoke-partner.localhost"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id)
|
||||
do update set feature_flags = excluded.feature_flags,
|
||||
admin_feature_flags = excluded.admin_feature_flags,
|
||||
public_config = excluded.public_config,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary)
|
||||
values ($1, 'smoke-partner.localhost', 'custom', 'active', true)
|
||||
on conflict (host)
|
||||
do update set tenant_id = excluded.tenant_id,
|
||||
status = excluded.status,
|
||||
is_primary = excluded.is_primary,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone,
|
||||
contact_email, invoice_title, invoice_type
|
||||
)
|
||||
values (
|
||||
$1,
|
||||
'烟测合作商有限公司',
|
||||
'91120000SMOKE',
|
||||
'Smoke Partner',
|
||||
'13900009999',
|
||||
'partner@example.test',
|
||||
'烟测合作商有限公司',
|
||||
'normal_vat'
|
||||
)
|
||||
on conflict (tenant_id)
|
||||
do update set billing_name = excluded.billing_name,
|
||||
tax_id = excluded.tax_id,
|
||||
contact_name = excluded.contact_name,
|
||||
contact_phone = excluded.contact_phone,
|
||||
contact_email = excluded.contact_email,
|
||||
invoice_title = excluded.invoice_title,
|
||||
invoice_type = excluded.invoice_type,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
id, tenant_id, plan_code, status, starts_at, expires_at,
|
||||
billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'starter_yearly', 'active',
|
||||
'2026-06-21T00:00:00Z', '2027-06-21T00:00:00Z',
|
||||
'yearly', 980000, '{"source":"smoke-seed"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
expires_at = excluded.expires_at,
|
||||
amount_cents = excluded.amount_cents,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerSubscription, ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoices (
|
||||
id, tenant_id, invoice_no, invoice_type, status, currency,
|
||||
subtotal_cents, discount_cents, tax_cents, total_cents,
|
||||
paid_cents, balance_cents, billing_period_start, billing_period_end,
|
||||
due_date, issued_at, note, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'BILL-SMOKE-20260621', 'subscription', 'paid', 'CNY',
|
||||
980000, 0, 0, 980000, 980000, 0,
|
||||
'2026-06-21', '2027-06-21', '2026-06-30',
|
||||
'2026-06-21T00:00:00Z', '烟测合作商年费', '{"source":"smoke-seed"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
paid_cents = excluded.paid_cents,
|
||||
balance_cents = excluded.balance_cents,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerInvoice, ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_items (
|
||||
id, tenant_id, invoice_id, item_type, description,
|
||||
quantity, unit_amount_cents, amount_cents, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'subscription', '合作商基础版 starter_yearly',
|
||||
1, 980000, 980000, '{"planCode":"starter_yearly"}'::jsonb
|
||||
)
|
||||
on conflict (id) do nothing
|
||||
`,
|
||||
[ids.partnerInvoiceItem, ids.partnerTenant, ids.partnerInvoice],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_payments (
|
||||
id, tenant_id, invoice_id, payment_no, provider, method, status,
|
||||
amount_cents, paid_at, provider_trade_no, raw_payload
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'PAY-SMOKE-20260621', 'manual', 'bank_transfer',
|
||||
'paid', 980000, '2026-06-21T00:00:00Z', 'SMOKE-TRANSFER',
|
||||
'{"source":"smoke-seed"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
amount_cents = excluded.amount_cents,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerInvoicePayment, ids.partnerTenant, ids.partnerInvoice],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.tenant_usage_records
|
||||
where tenant_id = $1
|
||||
and period_start = '2026-06-01'
|
||||
and period_end = '2026-06-30'
|
||||
and metadata->>'source' = 'smoke-seed'
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_usage_records (tenant_id, metric_key, metric_value, period_start, period_end, metadata)
|
||||
values
|
||||
($1, 'students', 120, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, 'questions', 860, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, 'storage_gb', 3.5, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb)
|
||||
`,
|
||||
[ids.partnerTenant],
|
||||
);
|
||||
|
||||
await client.query('commit');
|
||||
console.log(`Smoke seed complete for tenant ${tenantId}`);
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
55
supabase/config.toml
Normal file
55
supabase/config.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
project_id = "tiku-saas-local"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
port = 54321
|
||||
schemas = ["public", "storage", "graphql_public"]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
max_rows = 1000
|
||||
|
||||
[db]
|
||||
port = 54322
|
||||
shadow_port = 54320
|
||||
major_version = 15
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
port = 54329
|
||||
pool_mode = "transaction"
|
||||
default_pool_size = 20
|
||||
max_client_conn = 100
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
port = 54323
|
||||
api_url = "http://127.0.0.1:54321"
|
||||
|
||||
[inbucket]
|
||||
enabled = true
|
||||
port = 54324
|
||||
smtp_port = 54325
|
||||
pop3_port = 54326
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
file_size_limit = "100MiB"
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
site_url = "http://127.0.0.1:5173"
|
||||
additional_redirect_urls = ["http://127.0.0.1:5173", "http://localhost:5173"]
|
||||
jwt_expiry = 604800
|
||||
enable_refresh_token_rotation = true
|
||||
refresh_token_reuse_interval = 10
|
||||
enable_signup = true
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
policy = "oneshot"
|
||||
inspector_port = 8083
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
995
supabase/migrations/202606210001_core_multitenant_schema.sql
Normal file
995
supabase/migrations/202606210001_core_multitenant_schema.sql
Normal file
@@ -0,0 +1,995 @@
|
||||
create extension if not exists pgcrypto;
|
||||
create extension if not exists citext;
|
||||
|
||||
create schema if not exists app;
|
||||
create schema if not exists app_private;
|
||||
|
||||
revoke all on schema app_private from public;
|
||||
revoke all on schema app_private from anon;
|
||||
revoke all on schema app_private from authenticated;
|
||||
|
||||
create or replace function app.touch_updated_at()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function app.jwt_text(claim_name text)
|
||||
returns text
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select nullif(coalesce(auth.jwt() ->> claim_name, current_setting('request.jwt.claim.' || claim_name, true)), '')
|
||||
$$;
|
||||
|
||||
create or replace function app.current_tenant_id()
|
||||
returns uuid
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select app.jwt_text('tenant_id')::uuid
|
||||
$$;
|
||||
|
||||
create or replace function app.current_role()
|
||||
returns text
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select coalesce(app.jwt_text('app_role'), app.jwt_text('role'), '')
|
||||
$$;
|
||||
|
||||
create or replace function app.is_platform_admin()
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select app.current_role() in ('platform_admin', 'service_role')
|
||||
$$;
|
||||
|
||||
create table if not exists public.tenants (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug citext not null unique,
|
||||
name text not null,
|
||||
legal_name text,
|
||||
status text not null default 'active' check (status in ('draft', 'active', 'suspended', 'archived')),
|
||||
mode text not null default 'saas' check (mode in ('platform_owned', 'saas', 'dedicated')),
|
||||
billing_status text not null default 'trial' check (billing_status in ('trial', 'active', 'past_due', 'cancelled')),
|
||||
owner_user_id uuid,
|
||||
legacy_id text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.platform_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
auth_user_id uuid unique references auth.users(id) on delete set null,
|
||||
legacy_id text unique,
|
||||
username text,
|
||||
email citext,
|
||||
phone text,
|
||||
name text,
|
||||
avatar_url text,
|
||||
primary_role text not null default 'student',
|
||||
score integer not null default 0,
|
||||
last_seen_at timestamptz,
|
||||
legacy_password_hash text,
|
||||
password_migration_required boolean not null default false,
|
||||
raw_profile jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.tenants
|
||||
drop constraint if exists tenants_owner_user_id_fkey,
|
||||
add constraint tenants_owner_user_id_fkey
|
||||
foreign key (owner_user_id) references public.platform_users(id) on delete set null;
|
||||
|
||||
create table if not exists public.user_identities (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
provider text not null,
|
||||
provider_subject text not null,
|
||||
union_id text,
|
||||
open_id text,
|
||||
phone text,
|
||||
email citext,
|
||||
secret_payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (provider, provider_subject)
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_memberships (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
role text not null check (role in ('platform_admin', 'tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student')),
|
||||
status text not null default 'active' check (status in ('active', 'invited', 'disabled')),
|
||||
permissions jsonb not null default '{}'::jsonb,
|
||||
legacy_role text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, user_id, role)
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_domains (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
host citext not null unique,
|
||||
domain_type text not null default 'custom' check (domain_type in ('system', 'custom', 'miniapp')),
|
||||
status text not null default 'pending' check (status in ('pending', 'active', 'failed', 'disabled')),
|
||||
is_primary boolean not null default false,
|
||||
verification_token text,
|
||||
verified_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_branding (
|
||||
tenant_id uuid primary key references public.tenants(id) on delete cascade,
|
||||
brand_name text not null,
|
||||
short_name text,
|
||||
slogan text,
|
||||
org_name text,
|
||||
logo_url text,
|
||||
favicon_url text,
|
||||
service_wechat text,
|
||||
service_account_name text,
|
||||
theme jsonb not null default '{}'::jsonb,
|
||||
public_assets jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_settings (
|
||||
tenant_id uuid primary key references public.tenants(id) on delete cascade,
|
||||
feature_flags jsonb not null default '{}'::jsonb,
|
||||
admin_feature_flags jsonb not null default '{}'::jsonb,
|
||||
public_config jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_payment_accounts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
provider text not null,
|
||||
mode text not null default 'platform_collect' check (mode in ('platform_collect', 'tenant_collect', 'service_provider')),
|
||||
display_name text,
|
||||
status text not null default 'disabled' check (status in ('active', 'disabled', 'pending')),
|
||||
config_public jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider)
|
||||
);
|
||||
|
||||
create table if not exists app_private.tenant_secrets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
secret_scope text not null check (secret_scope in ('payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system')),
|
||||
secret_key text not null,
|
||||
secret_value text,
|
||||
secret_json jsonb not null default '{}'::jsonb,
|
||||
provider text,
|
||||
last_rotated_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, secret_scope, secret_key)
|
||||
);
|
||||
|
||||
comment on table app_private.tenant_secrets is
|
||||
'Private tenant secrets. Do not expose through PostgREST/anon/authenticated roles. Prefer external vault or encrypted values in production.';
|
||||
|
||||
create table if not exists public.tenant_subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
plan_code text not null,
|
||||
status text not null default 'trial' check (status in ('trial', 'active', 'past_due', 'cancelled')),
|
||||
starts_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
billing_cycle text default 'yearly',
|
||||
amount_cents integer not null default 0,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_usage_records (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
metric_key text not null,
|
||||
metric_value numeric not null default 0,
|
||||
period_start date not null,
|
||||
period_end date not null,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.regions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
code text,
|
||||
short_name text,
|
||||
full_name text,
|
||||
icon text,
|
||||
pinyin text,
|
||||
sort_order integer not null default 0,
|
||||
is_hot boolean not null default false,
|
||||
is_active boolean not null default true,
|
||||
config jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.region_modules (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
type text,
|
||||
icon text,
|
||||
color text,
|
||||
text_color text,
|
||||
description text,
|
||||
route text,
|
||||
sort_order integer not null default 0,
|
||||
is_primary_school_module boolean not null default false,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.module_nodes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
module_id uuid references public.region_modules(id) on delete set null,
|
||||
parent_id uuid references public.module_nodes(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_parent_id text,
|
||||
legacy_module_id text,
|
||||
type text not null check (type in ('category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom')),
|
||||
name text not null,
|
||||
path text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.schools (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
module_id uuid references public.region_modules(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
professional_exam_date text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.majors (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
school_id uuid references public.schools(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
description text,
|
||||
study_tips text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.subjects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
module_id uuid references public.region_modules(id) on delete set null,
|
||||
school_id uuid references public.schools(id) on delete set null,
|
||||
major_id uuid references public.majors(id) on delete set null,
|
||||
node_id uuid references public.module_nodes(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
type text check (type in ('cultural', 'professional')),
|
||||
major_legacy_ids jsonb not null default '[]'::jsonb,
|
||||
icon text,
|
||||
description text,
|
||||
stats jsonb not null default '{}'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.categories (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
subject_id uuid references public.subjects(id) on delete cascade,
|
||||
node_id uuid references public.module_nodes(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
category_type text check (category_type in ('chapter', 'paper')),
|
||||
sort_order integer not null default 0,
|
||||
svip_question_limit integer,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.question_banks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
name text not null,
|
||||
source_scope text not null default 'tenant' check (source_scope in ('platform', 'tenant')),
|
||||
status text not null default 'active' check (status in ('active', 'archived')),
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.questions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
question_bank_id uuid references public.question_banks(id) on delete set null,
|
||||
subject_id uuid references public.subjects(id) on delete set null,
|
||||
category_id uuid references public.categories(id) on delete set null,
|
||||
node_id uuid references public.module_nodes(id) on delete set null,
|
||||
legacy_id text,
|
||||
legacy_subject_id text,
|
||||
legacy_category_id text,
|
||||
legacy_node_id text,
|
||||
type text not null default 'choice',
|
||||
type_label text,
|
||||
difficulty integer,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
media_url text,
|
||||
has_video_explanation boolean not null default false,
|
||||
status text not null default 'published' check (status in ('draft', 'published', 'archived')),
|
||||
current_version_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.question_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
question_id uuid not null references public.questions(id) on delete cascade,
|
||||
version_no integer not null default 1,
|
||||
content text,
|
||||
options jsonb not null default '[]'::jsonb,
|
||||
correct_option_index integer,
|
||||
correct_option_indices jsonb not null default '[]'::jsonb,
|
||||
answer_text text,
|
||||
explanation text,
|
||||
sub_questions jsonb not null default '[]'::jsonb,
|
||||
code_lang text,
|
||||
code_template text,
|
||||
source_hash text,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (question_id, version_no)
|
||||
);
|
||||
|
||||
alter table public.questions
|
||||
drop constraint if exists questions_current_version_id_fkey,
|
||||
add constraint questions_current_version_id_fkey
|
||||
foreign key (current_version_id) references public.question_versions(id) on delete set null;
|
||||
|
||||
create table if not exists public.student_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
legacy_user_id text,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
selected_school_id uuid references public.schools(id) on delete set null,
|
||||
selected_major_id uuid references public.majors(id) on delete set null,
|
||||
questions_answered_today integer not null default 0,
|
||||
mastered_words_count integer not null default 0,
|
||||
last_check_in_date date,
|
||||
stats jsonb not null default '{}'::jsonb,
|
||||
progress jsonb not null default '{}'::jsonb,
|
||||
module_selections jsonb not null default '{}'::jsonb,
|
||||
recent_activities jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, user_id)
|
||||
);
|
||||
|
||||
create table if not exists public.practice_sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
mode text not null default 'chapter',
|
||||
target_type text,
|
||||
target_id uuid,
|
||||
started_at timestamptz not null default now(),
|
||||
finished_at timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb
|
||||
);
|
||||
|
||||
create table if not exists public.answer_records (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
question_id uuid references public.questions(id) on delete set null,
|
||||
question_version_id uuid references public.question_versions(id) on delete set null,
|
||||
practice_session_id uuid references public.practice_sessions(id) on delete set null,
|
||||
legacy_id text,
|
||||
legacy_question_id text,
|
||||
legacy_category_id text,
|
||||
selected_options jsonb not null default '[]'::jsonb,
|
||||
answer_text text,
|
||||
is_correct boolean,
|
||||
answered_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.favorite_questions (
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
question_id uuid not null references public.questions(id) on delete cascade,
|
||||
source text not null default 'imported',
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (tenant_id, user_id, question_id)
|
||||
);
|
||||
|
||||
create table if not exists public.wrong_questions (
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
question_id uuid not null references public.questions(id) on delete cascade,
|
||||
wrong_count integer not null default 1,
|
||||
last_wrong_at timestamptz not null default now(),
|
||||
resolved_at timestamptz,
|
||||
primary key (tenant_id, user_id, question_id)
|
||||
);
|
||||
|
||||
create table if not exists public.svip_plans (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
price_cents integer not null default 0,
|
||||
original_price_cents integer,
|
||||
days integer not null default 0,
|
||||
description text,
|
||||
per_day_label text,
|
||||
badge text,
|
||||
recommended boolean not null default false,
|
||||
coupon_only boolean not null default false,
|
||||
vp_product_id text,
|
||||
vp_enabled boolean not null default false,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.orders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
legacy_id text,
|
||||
legacy_user_id text,
|
||||
order_no text not null,
|
||||
status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'closed', 'refunded')),
|
||||
product_type text,
|
||||
product_name text,
|
||||
amount_cents integer not null default 0,
|
||||
pay_method text,
|
||||
pay_provider text,
|
||||
trade_no text,
|
||||
plan_id uuid references public.svip_plans(id) on delete set null,
|
||||
legacy_plan_id text,
|
||||
days integer,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
paid_at timestamptz,
|
||||
raw_payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, order_no),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.order_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
order_id uuid not null references public.orders(id) on delete cascade,
|
||||
legacy_id text,
|
||||
item_type text not null,
|
||||
item_id uuid,
|
||||
name text not null,
|
||||
quantity integer not null default 1,
|
||||
unit_amount_cents integer not null default 0,
|
||||
total_amount_cents integer not null default 0,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.payments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
order_id uuid not null references public.orders(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_order_id text,
|
||||
provider text not null,
|
||||
method text,
|
||||
status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'cancelled', 'refunded')),
|
||||
amount_cents integer not null,
|
||||
provider_trade_no text,
|
||||
paid_at timestamptz,
|
||||
raw_payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.payment_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
payment_id uuid references public.payments(id) on delete set null,
|
||||
provider text not null,
|
||||
event_type text not null,
|
||||
event_id text,
|
||||
signature_valid boolean,
|
||||
payload jsonb not null,
|
||||
processed_at timestamptz,
|
||||
error text,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (provider, event_id)
|
||||
);
|
||||
|
||||
create table if not exists public.entitlements (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
entitlement_type text not null default 'svip',
|
||||
scope_type text not null default 'tenant' check (scope_type in ('tenant', 'region', 'module', 'subject', 'question_bank')),
|
||||
scope_id uuid,
|
||||
source_type text not null default 'migration',
|
||||
source_id uuid,
|
||||
legacy_source_id text,
|
||||
starts_at timestamptz not null default now(),
|
||||
expires_at timestamptz,
|
||||
status text not null default 'active' check (status in ('active', 'revoked', 'expired')),
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.code_batches (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
sale_type text,
|
||||
channel text,
|
||||
campaign_name text,
|
||||
default_unit_price_cents integer not null default 0,
|
||||
cost_price_cents integer not null default 0,
|
||||
total_count integer not null default 0,
|
||||
days integer,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
issued_at timestamptz,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
remark text,
|
||||
commission_rate numeric(6,4),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.activation_codes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
code citext not null,
|
||||
days integer not null default 0,
|
||||
is_used boolean not null default false,
|
||||
used_by uuid references public.platform_users(id) on delete set null,
|
||||
used_at timestamptz,
|
||||
agent_user_id uuid references public.platform_users(id) on delete set null,
|
||||
batch_id uuid references public.code_batches(id) on delete set null,
|
||||
sale_type text,
|
||||
unit_price_cents integer,
|
||||
sold_to text,
|
||||
used_region_id uuid references public.regions(id) on delete set null,
|
||||
coupon_code text,
|
||||
coupon_redemption_id uuid,
|
||||
remark text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, code),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.coupons (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
code citext not null,
|
||||
plan_id uuid references public.svip_plans(id) on delete set null,
|
||||
discount_type text check (discount_type in ('percent', 'fixed')),
|
||||
discount_value numeric,
|
||||
valid_from timestamptz,
|
||||
valid_to timestamptz,
|
||||
max_uses integer,
|
||||
used_count integer not null default 0,
|
||||
source text,
|
||||
remark text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, code),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.coupon_redemptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
coupon_id uuid references public.coupons(id) on delete set null,
|
||||
coupon_code text,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
plan_id uuid references public.svip_plans(id) on delete set null,
|
||||
order_id uuid references public.orders(id) on delete set null,
|
||||
status text not null default 'claimed',
|
||||
discount_applied_cents integer,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
source text,
|
||||
remark text,
|
||||
claimed_at timestamptz,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.vocabulary_units (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
description text,
|
||||
word_count integer,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.vocabulary_words (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
unit_id uuid references public.vocabulary_units(id) on delete set null,
|
||||
legacy_id text,
|
||||
word text not null,
|
||||
phonetic text,
|
||||
meaning text,
|
||||
example text,
|
||||
example_translation text,
|
||||
difficulty integer,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.handbook_subjects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
type text,
|
||||
icon text,
|
||||
color text,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.handbook_chapters (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
subject_id uuid references public.handbook_subjects(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.handbook_entries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
chapter_id uuid references public.handbook_chapters(id) on delete cascade,
|
||||
legacy_id text,
|
||||
title text not null,
|
||||
summary text,
|
||||
content text,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.banners (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
title text,
|
||||
subtitle text,
|
||||
content text,
|
||||
button_text text,
|
||||
button_link text,
|
||||
bg_color text,
|
||||
border_color text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.faqs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
question text,
|
||||
answer text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.announcements (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
content text,
|
||||
link text,
|
||||
bg_color text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.reports (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
question_id uuid references public.questions(id) on delete set null,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
type text,
|
||||
description text,
|
||||
status text not null default 'pending',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.audit_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid references public.tenants(id) on delete cascade,
|
||||
actor_user_id uuid references public.platform_users(id) on delete set null,
|
||||
action text not null,
|
||||
target_type text,
|
||||
target_id text,
|
||||
details jsonb not null default '{}'::jsonb,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.crm_config (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
enabled boolean not null default false,
|
||||
url text,
|
||||
secret_ref text,
|
||||
form_name text,
|
||||
exam_type text,
|
||||
timeout_sec integer,
|
||||
delay_sec integer,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id)
|
||||
);
|
||||
|
||||
create table if not exists public.pb_import_runs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
source_name text not null,
|
||||
source_kind text not null default 'json',
|
||||
status text not null default 'running' check (status in ('running', 'completed', 'failed')),
|
||||
stats jsonb not null default '{}'::jsonb,
|
||||
started_at timestamptz not null default now(),
|
||||
finished_at timestamptz
|
||||
);
|
||||
|
||||
create table if not exists public.pb_raw_records (
|
||||
run_id uuid not null references public.pb_import_runs(id) on delete cascade,
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
collection_name text not null,
|
||||
legacy_id text not null,
|
||||
record jsonb not null,
|
||||
normalized boolean not null default false,
|
||||
errors jsonb not null default '[]'::jsonb,
|
||||
imported_at timestamptz not null default now(),
|
||||
primary key (run_id, collection_name, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.pb_import_issues (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
run_id uuid references public.pb_import_runs(id) on delete cascade,
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
collection_name text not null,
|
||||
legacy_id text,
|
||||
severity text not null check (severity in ('info', 'warning', 'error', 'critical')),
|
||||
issue_code text not null,
|
||||
message text not null,
|
||||
field_path text,
|
||||
raw_value_sample text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_tenant_domains_host on public.tenant_domains(host);
|
||||
create index if not exists idx_memberships_tenant_user on public.tenant_memberships(tenant_id, user_id);
|
||||
create index if not exists idx_regions_tenant_active on public.regions(tenant_id, is_active, sort_order);
|
||||
create index if not exists idx_module_nodes_tenant_parent on public.module_nodes(tenant_id, parent_id, sort_order);
|
||||
create index if not exists idx_questions_tenant_subject on public.questions(tenant_id, subject_id);
|
||||
create index if not exists idx_questions_tenant_node on public.questions(tenant_id, node_id);
|
||||
create index if not exists idx_question_versions_question on public.question_versions(question_id, version_no desc);
|
||||
create index if not exists idx_orders_tenant_status on public.orders(tenant_id, status, created_at desc);
|
||||
create index if not exists idx_orders_tenant_user on public.orders(tenant_id, user_id, created_at desc);
|
||||
create index if not exists idx_entitlements_tenant_user on public.entitlements(tenant_id, user_id, status, expires_at);
|
||||
create index if not exists idx_raw_records_lookup on public.pb_raw_records(tenant_id, collection_name, legacy_id);
|
||||
create index if not exists idx_import_issues_run on public.pb_import_issues(run_id, severity, collection_name);
|
||||
create index if not exists idx_tenant_secrets_lookup on app_private.tenant_secrets(tenant_id, secret_scope, secret_key);
|
||||
|
||||
alter table public.tenants enable row level security;
|
||||
alter table public.platform_users enable row level security;
|
||||
alter table public.user_identities enable row level security;
|
||||
alter table app_private.tenant_secrets enable row level security;
|
||||
|
||||
drop policy if exists platform_admin_tenants on public.tenants;
|
||||
create policy platform_admin_tenants on public.tenants
|
||||
for all
|
||||
using (app.is_platform_admin())
|
||||
with check (app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_member_can_read_own_tenants on public.tenants;
|
||||
create policy tenant_member_can_read_own_tenants on public.tenants
|
||||
for select
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users pu on pu.id = tm.user_id
|
||||
where tm.tenant_id = tenants.id
|
||||
and pu.auth_user_id = auth.uid()
|
||||
and tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists platform_admin_platform_users on public.platform_users;
|
||||
create policy platform_admin_platform_users on public.platform_users
|
||||
for all
|
||||
using (app.is_platform_admin() or auth_user_id = auth.uid())
|
||||
with check (app.is_platform_admin() or auth_user_id = auth.uid());
|
||||
|
||||
drop policy if exists platform_admin_user_identities on public.user_identities;
|
||||
create policy platform_admin_user_identities on public.user_identities
|
||||
for all
|
||||
using (
|
||||
app.is_platform_admin()
|
||||
or exists (
|
||||
select 1 from public.platform_users pu
|
||||
where pu.id = user_id and pu.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
with check (
|
||||
app.is_platform_admin()
|
||||
or exists (
|
||||
select 1 from public.platform_users pu
|
||||
where pu.id = user_id and pu.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists platform_admin_tenant_secrets on app_private.tenant_secrets;
|
||||
create policy platform_admin_tenant_secrets on app_private.tenant_secrets
|
||||
for all
|
||||
using (app.is_platform_admin())
|
||||
with check (app.is_platform_admin());
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'tenant_domains', 'tenant_branding', 'tenant_settings', 'tenant_payment_accounts',
|
||||
'tenant_subscriptions', 'tenant_usage_records', 'tenant_memberships',
|
||||
'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects',
|
||||
'categories', 'question_banks', 'questions', 'question_versions',
|
||||
'student_profiles', 'practice_sessions', 'answer_records', 'favorite_questions',
|
||||
'wrong_questions', 'svip_plans', 'orders', 'order_items', 'payments',
|
||||
'payment_events', 'entitlements', 'code_batches', 'activation_codes',
|
||||
'coupons', 'coupon_redemptions', 'vocabulary_units', 'vocabulary_words',
|
||||
'handbook_subjects', 'handbook_chapters', 'handbook_entries', 'banners',
|
||||
'faqs', 'announcements', 'reports', 'audit_logs', 'crm_config',
|
||||
'pb_import_runs', 'pb_raw_records', 'pb_import_issues'
|
||||
]
|
||||
loop
|
||||
execute format('alter table public.%I enable row level security', table_name);
|
||||
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||
execute format(
|
||||
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||
table_name
|
||||
);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'tenants', 'platform_users', 'user_identities', 'tenant_domains', 'tenant_branding',
|
||||
'tenant_settings', 'tenant_payment_accounts', 'tenant_subscriptions',
|
||||
'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects',
|
||||
'categories', 'question_banks', 'questions', 'student_profiles', 'svip_plans',
|
||||
'orders', 'payments', 'code_batches', 'activation_codes', 'coupons',
|
||||
'coupon_redemptions', 'vocabulary_units', 'vocabulary_words', 'handbook_subjects',
|
||||
'handbook_chapters', 'handbook_entries', 'banners', 'faqs', 'announcements',
|
||||
'reports', 'crm_config'
|
||||
]
|
||||
loop
|
||||
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
|
||||
end loop;
|
||||
end $$;
|
||||
@@ -0,0 +1,257 @@
|
||||
create table if not exists public.products (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
title text not null,
|
||||
price_label text,
|
||||
link text,
|
||||
type text,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
cover text,
|
||||
preview_iframe text,
|
||||
detail_images jsonb not null default '[]'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
status text not null default 'active' check (status in ('active', 'inactive', 'archived')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.timelines (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
school_id uuid references public.schools(id) on delete set null,
|
||||
legacy_id text,
|
||||
type text,
|
||||
title text not null,
|
||||
description text,
|
||||
event_date date,
|
||||
link text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.video_explanations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
title text not null,
|
||||
description text,
|
||||
video_url text,
|
||||
thumbnail_url text,
|
||||
duration_seconds integer,
|
||||
knowledge_tags jsonb not null default '[]'::jsonb,
|
||||
is_general boolean not null default false,
|
||||
subject_id uuid references public.subjects(id) on delete set null,
|
||||
legacy_subject_id text,
|
||||
difficulty integer,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.question_videos (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
question_id uuid references public.questions(id) on delete cascade,
|
||||
video_id uuid references public.video_explanations(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_question_id text,
|
||||
legacy_video_id text,
|
||||
video_type text not null default 'specific',
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.scoreline_schools (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
short_name text,
|
||||
type text,
|
||||
is_hot boolean not null default false,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.scoreline_majors (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
school_id uuid references public.scoreline_schools(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
sort_order integer not null default 0,
|
||||
has_restriction boolean not null default false,
|
||||
restriction_desc text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.scoreline_fields (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_id text,
|
||||
field_key text not null,
|
||||
field_name text not null,
|
||||
field_type text,
|
||||
unit text,
|
||||
is_filter boolean not null default false,
|
||||
is_required boolean not null default false,
|
||||
is_visible boolean not null default true,
|
||||
is_trend boolean not null default false,
|
||||
options jsonb not null default '[]'::jsonb,
|
||||
placeholder text,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, region_id, field_key)
|
||||
);
|
||||
|
||||
create table if not exists public.scoreline_records (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
school_id uuid references public.scoreline_schools(id) on delete set null,
|
||||
major_id uuid references public.scoreline_majors(id) on delete set null,
|
||||
legacy_id text,
|
||||
year integer not null,
|
||||
school_name text,
|
||||
major_name text,
|
||||
field_values jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.referral_tracks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
event_type text not null,
|
||||
ref_code text,
|
||||
ref_user_id uuid references public.platform_users(id) on delete set null,
|
||||
target_user_id uuid references public.platform_users(id) on delete set null,
|
||||
source text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.badges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
description text,
|
||||
category text,
|
||||
icon_url text,
|
||||
level integer,
|
||||
unlock_type text,
|
||||
condition_field text,
|
||||
condition_operator text,
|
||||
condition_value numeric,
|
||||
condition_extra jsonb not null default '{}'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.user_badges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete cascade,
|
||||
badge_id uuid references public.badges(id) on delete cascade,
|
||||
granted_by uuid references public.platform_users(id) on delete set null,
|
||||
legacy_id text,
|
||||
note text,
|
||||
granted_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.crm_webhook_queue (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
record_id text,
|
||||
status text not null default 'pending',
|
||||
scheduled_at timestamptz,
|
||||
attempts integer not null default 0,
|
||||
next_attempt_at timestamptz,
|
||||
last_error text,
|
||||
last_http_code integer,
|
||||
lead_id text,
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.crm_webhook_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
record_id text,
|
||||
http_code integer,
|
||||
outcome text,
|
||||
error_message text,
|
||||
lead_id text,
|
||||
request_body text,
|
||||
response_summary text,
|
||||
signed_at timestamptz,
|
||||
attempt integer,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_products_tenant_region on public.products(tenant_id, region_id, sort_order);
|
||||
create index if not exists idx_timelines_tenant_region on public.timelines(tenant_id, region_id, event_date);
|
||||
create index if not exists idx_video_explanations_tenant_subject on public.video_explanations(tenant_id, subject_id);
|
||||
create index if not exists idx_scoreline_records_tenant_year on public.scoreline_records(tenant_id, region_id, year desc);
|
||||
create index if not exists idx_referral_tracks_tenant_event on public.referral_tracks(tenant_id, event_type, created_at desc);
|
||||
create index if not exists idx_crm_queue_tenant_status on public.crm_webhook_queue(tenant_id, status, next_attempt_at);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'products', 'timelines', 'video_explanations', 'question_videos',
|
||||
'scoreline_schools', 'scoreline_majors', 'scoreline_fields', 'scoreline_records',
|
||||
'referral_tracks', 'badges', 'user_badges', 'crm_webhook_queue', 'crm_webhook_log'
|
||||
]
|
||||
loop
|
||||
execute format('alter table public.%I enable row level security', table_name);
|
||||
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||
execute format(
|
||||
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||
table_name
|
||||
);
|
||||
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
|
||||
end loop;
|
||||
end $$;
|
||||
@@ -0,0 +1,180 @@
|
||||
create table if not exists public.exam_dates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
school_id uuid references public.schools(id) on delete set null,
|
||||
legacy_id text,
|
||||
exam_name text not null,
|
||||
exam_date date,
|
||||
exam_type text,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.question_type_groups (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
subject_id uuid references public.subjects(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_subject_id text,
|
||||
display_name text not null,
|
||||
types jsonb not null default '[]'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.subject_shares (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
source_subject_id uuid references public.subjects(id) on delete cascade,
|
||||
target_subject_id uuid references public.subjects(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_source_subject_id text,
|
||||
legacy_target_subject_id text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.recent_practices (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete cascade,
|
||||
legacy_id text,
|
||||
practice_type text,
|
||||
target_legacy_id text,
|
||||
target_name text,
|
||||
progress integer not null default 0,
|
||||
color text,
|
||||
last_access_at timestamptz,
|
||||
last_practice_at timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.user_word_progress (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete cascade,
|
||||
word_id uuid references public.vocabulary_words(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_user_id text,
|
||||
legacy_word_id text,
|
||||
status text not null default 'new' check (status in ('new', 'learning', 'mastered', 'reviewing')),
|
||||
correct_count integer not null default 0,
|
||||
wrong_count integer not null default 0,
|
||||
last_review_date timestamptz,
|
||||
next_review_date timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, user_id, word_id)
|
||||
);
|
||||
|
||||
create table if not exists public.user_word_favorites (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete cascade,
|
||||
word_id uuid references public.vocabulary_words(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_user_id text,
|
||||
legacy_word_id text,
|
||||
note text,
|
||||
favorited_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, user_id, word_id)
|
||||
);
|
||||
|
||||
create table if not exists public.content_assets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
asset_key text,
|
||||
title text,
|
||||
category text,
|
||||
description text,
|
||||
file_name text,
|
||||
cdn_url text,
|
||||
is_public boolean not null default false,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create table if not exists public.dashboard_daily_stats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
stat_date date not null,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
new_users integer not null default 0,
|
||||
new_questions integer not null default 0,
|
||||
new_orders integer not null default 0,
|
||||
new_revenue_cents integer not null default 0,
|
||||
active_users integer not null default 0,
|
||||
rebuilt_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, stat_date, legacy_region_id)
|
||||
);
|
||||
|
||||
create table if not exists public.revenue_daily_stats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
stat_date date not null,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
sale_type text,
|
||||
real_revenue_cents integer not null default 0,
|
||||
order_count integer not null default 0,
|
||||
code_count integer not null default 0,
|
||||
code_used integer not null default 0,
|
||||
code_estimated_cents integer not null default 0,
|
||||
estimated_revenue_cents integer not null default 0,
|
||||
rebuilt_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, stat_date, legacy_region_id, sale_type)
|
||||
);
|
||||
|
||||
create index if not exists idx_exam_dates_tenant_region on public.exam_dates(tenant_id, region_id, exam_date);
|
||||
create index if not exists idx_question_type_groups_subject on public.question_type_groups(tenant_id, subject_id, sort_order);
|
||||
create index if not exists idx_recent_practices_user on public.recent_practices(tenant_id, user_id, last_practice_at desc);
|
||||
create index if not exists idx_user_word_progress_user on public.user_word_progress(tenant_id, user_id, status);
|
||||
create index if not exists idx_content_assets_tenant_key on public.content_assets(tenant_id, asset_key);
|
||||
create index if not exists idx_dashboard_daily_stats_date on public.dashboard_daily_stats(tenant_id, stat_date desc);
|
||||
create index if not exists idx_revenue_daily_stats_date on public.revenue_daily_stats(tenant_id, stat_date desc);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'exam_dates', 'question_type_groups', 'subject_shares', 'recent_practices',
|
||||
'user_word_progress', 'user_word_favorites', 'content_assets',
|
||||
'dashboard_daily_stats', 'revenue_daily_stats'
|
||||
]
|
||||
loop
|
||||
execute format('alter table public.%I enable row level security', table_name);
|
||||
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||
execute format(
|
||||
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||
table_name
|
||||
);
|
||||
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
|
||||
end loop;
|
||||
end $$;
|
||||
122
supabase/migrations/202606210004_auth_china_login_extensions.sql
Normal file
122
supabase/migrations/202606210004_auth_china_login_extensions.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
create table if not exists public.tenant_auth_providers (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
provider text not null,
|
||||
status text not null default 'disabled' check (status in ('active', 'disabled', 'testing')),
|
||||
display_name text,
|
||||
config_public jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider)
|
||||
);
|
||||
|
||||
comment on table public.tenant_auth_providers is
|
||||
'Public, non-secret auth provider settings for each tenant. Secrets stay in app_private.tenant_secrets or an external vault.';
|
||||
|
||||
create table if not exists public.sms_verification_codes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
phone text not null,
|
||||
purpose text not null default 'login' check (purpose in ('login', 'bind_phone', 'reset_password')),
|
||||
code_hash text not null,
|
||||
provider text not null default 'mock',
|
||||
status text not null default 'pending' check (status in ('pending', 'sent', 'verified', 'expired', 'blocked')),
|
||||
attempts integer not null default 0,
|
||||
expires_at timestamptz not null,
|
||||
consumed_at timestamptz,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.sms_verification_codes is
|
||||
'SMS verification records. Plain verification codes are never stored; only one-way hashes are kept.';
|
||||
|
||||
create table if not exists public.auth_login_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
provider text not null,
|
||||
identifier text,
|
||||
result text not null check (result in ('sent', 'success', 'failed', 'blocked')),
|
||||
failure_code text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists app_private.auth_sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
provider text not null,
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table app_private.auth_sessions is
|
||||
'API-issued session token hashes for the migration period before full Supabase Auth JWT adoption.';
|
||||
|
||||
create index if not exists idx_auth_providers_tenant_status
|
||||
on public.tenant_auth_providers(tenant_id, provider, status);
|
||||
|
||||
create index if not exists idx_sms_codes_tenant_phone_purpose
|
||||
on public.sms_verification_codes(tenant_id, phone, purpose, expires_at desc);
|
||||
|
||||
create index if not exists idx_sms_codes_pending_lookup
|
||||
on public.sms_verification_codes(tenant_id, phone, purpose, created_at desc)
|
||||
where consumed_at is null and status in ('pending', 'sent');
|
||||
|
||||
create index if not exists idx_auth_login_events_tenant_user
|
||||
on public.auth_login_events(tenant_id, user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_auth_sessions_user
|
||||
on app_private.auth_sessions(tenant_id, user_id, expires_at desc)
|
||||
where revoked_at is null;
|
||||
|
||||
alter table public.tenant_auth_providers enable row level security;
|
||||
alter table public.sms_verification_codes enable row level security;
|
||||
alter table public.auth_login_events enable row level security;
|
||||
alter table app_private.auth_sessions enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.tenant_auth_providers;
|
||||
create policy tenant_isolation on public.tenant_auth_providers
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.sms_verification_codes;
|
||||
create policy tenant_isolation on public.sms_verification_codes
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.auth_login_events;
|
||||
create policy tenant_isolation on public.auth_login_events
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists platform_admin_auth_sessions on app_private.auth_sessions;
|
||||
create policy platform_admin_auth_sessions on app_private.auth_sessions
|
||||
for all
|
||||
using (app.is_platform_admin())
|
||||
with check (app.is_platform_admin());
|
||||
|
||||
drop trigger if exists set_updated_at on public.tenant_auth_providers;
|
||||
create trigger set_updated_at
|
||||
before update on public.tenant_auth_providers
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on app_private.auth_sessions;
|
||||
create trigger set_updated_at
|
||||
before update on app_private.auth_sessions
|
||||
for each row execute function app.touch_updated_at();
|
||||
181
supabase/migrations/202606210005_platform_admin_billing.sql
Normal file
181
supabase/migrations/202606210005_platform_admin_billing.sql
Normal file
@@ -0,0 +1,181 @@
|
||||
create table if not exists public.platform_saas_plans (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
description text,
|
||||
billing_cycle text not null default 'yearly' check (billing_cycle in ('monthly', 'quarterly', 'yearly', 'one_time')),
|
||||
base_amount_cents integer not null default 0,
|
||||
currency text not null default 'CNY',
|
||||
included_quotas jsonb not null default '{}'::jsonb,
|
||||
overage_prices jsonb not null default '{}'::jsonb,
|
||||
feature_flags jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'active' check (status in ('active', 'archived')),
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_billing_profiles (
|
||||
tenant_id uuid primary key references public.tenants(id) on delete cascade,
|
||||
billing_name text,
|
||||
tax_id text,
|
||||
contact_name text,
|
||||
contact_phone text,
|
||||
contact_email citext,
|
||||
billing_address text,
|
||||
invoice_title text,
|
||||
invoice_type text check (invoice_type in ('none', 'normal_vat', 'special_vat')),
|
||||
bank_name text,
|
||||
bank_account_masked text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_invoices (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_no text not null unique,
|
||||
invoice_type text not null default 'subscription' check (invoice_type in ('subscription', 'service_fee', 'usage_overage', 'manual_adjustment')),
|
||||
status text not null default 'draft' check (status in ('draft', 'issued', 'paid', 'void', 'overdue')),
|
||||
currency text not null default 'CNY',
|
||||
subtotal_cents integer not null default 0,
|
||||
discount_cents integer not null default 0,
|
||||
tax_cents integer not null default 0,
|
||||
total_cents integer not null default 0,
|
||||
paid_cents integer not null default 0,
|
||||
balance_cents integer not null default 0,
|
||||
billing_period_start date,
|
||||
billing_period_end date,
|
||||
due_date date,
|
||||
issued_at timestamptz,
|
||||
paid_at timestamptz,
|
||||
note text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_invoice_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
|
||||
item_type text not null default 'subscription',
|
||||
item_ref_id uuid,
|
||||
description text not null,
|
||||
quantity numeric(12,2) not null default 1,
|
||||
unit_amount_cents integer not null default 0,
|
||||
amount_cents integer not null default 0,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_invoice_payments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
|
||||
payment_no text not null unique,
|
||||
provider text not null default 'manual',
|
||||
method text,
|
||||
status text not null default 'paid' check (status in ('pending', 'paid', 'failed', 'refunded')),
|
||||
amount_cents integer not null default 0,
|
||||
paid_at timestamptz,
|
||||
provider_trade_no text,
|
||||
received_by uuid references public.platform_users(id) on delete set null,
|
||||
raw_payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_saas_plans_status on public.platform_saas_plans(status, sort_order);
|
||||
create index if not exists idx_tenant_subscriptions_tenant_status on public.tenant_subscriptions(tenant_id, status, expires_at desc);
|
||||
create index if not exists idx_tenant_usage_records_lookup on public.tenant_usage_records(tenant_id, metric_key, period_start, period_end);
|
||||
create index if not exists idx_tenant_invoices_tenant_status on public.tenant_invoices(tenant_id, status, due_date desc);
|
||||
create index if not exists idx_tenant_invoice_items_invoice on public.tenant_invoice_items(invoice_id);
|
||||
create index if not exists idx_tenant_invoice_payments_invoice on public.tenant_invoice_payments(invoice_id, status);
|
||||
|
||||
alter table public.platform_saas_plans enable row level security;
|
||||
alter table public.tenant_billing_profiles enable row level security;
|
||||
alter table public.tenant_invoices enable row level security;
|
||||
alter table public.tenant_invoice_items enable row level security;
|
||||
alter table public.tenant_invoice_payments enable row level security;
|
||||
|
||||
drop policy if exists platform_admin_saas_plans on public.platform_saas_plans;
|
||||
create policy platform_admin_saas_plans on public.platform_saas_plans
|
||||
for all
|
||||
using (app.is_platform_admin())
|
||||
with check (app.is_platform_admin());
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'tenant_billing_profiles',
|
||||
'tenant_invoices',
|
||||
'tenant_invoice_items',
|
||||
'tenant_invoice_payments'
|
||||
]
|
||||
loop
|
||||
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||
execute format(
|
||||
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||
table_name
|
||||
);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'platform_saas_plans',
|
||||
'tenant_billing_profiles',
|
||||
'tenant_invoices',
|
||||
'tenant_invoice_payments'
|
||||
]
|
||||
loop
|
||||
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
insert into public.platform_saas_plans (
|
||||
code, name, description, billing_cycle, base_amount_cents,
|
||||
included_quotas, overage_prices, feature_flags, sort_order
|
||||
)
|
||||
values
|
||||
(
|
||||
'starter_yearly',
|
||||
'合作商基础版',
|
||||
'适合单地区题库合作商,含基础品牌与域名能力。',
|
||||
'yearly',
|
||||
980000,
|
||||
'{"students":1000,"questions":5000,"storageGb":20}'::jsonb,
|
||||
'{"studentsExtraPerYearCents":500,"storageGbPerYearCents":12000}'::jsonb,
|
||||
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":false}'::jsonb,
|
||||
10
|
||||
),
|
||||
(
|
||||
'pro_yearly',
|
||||
'合作商专业版',
|
||||
'适合多地区、多课程运营,支持更多运营工具。',
|
||||
'yearly',
|
||||
1980000,
|
||||
'{"students":5000,"questions":30000,"storageGb":100}'::jsonb,
|
||||
'{"studentsExtraPerYearCents":300,"storageGbPerYearCents":9000}'::jsonb,
|
||||
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":true,"crmWebhook":true}'::jsonb,
|
||||
20
|
||||
)
|
||||
on conflict (code)
|
||||
do update set name = excluded.name,
|
||||
description = excluded.description,
|
||||
billing_cycle = excluded.billing_cycle,
|
||||
base_amount_cents = excluded.base_amount_cents,
|
||||
included_quotas = excluded.included_quotas,
|
||||
overage_prices = excluded.overage_prices,
|
||||
feature_flags = excluded.feature_flags,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now();
|
||||
150
supabase/migrations/202606210006_growth_referral_crm.sql
Normal file
150
supabase/migrations/202606210006_growth_referral_crm.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
create table if not exists public.referral_codes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
code citext not null,
|
||||
status text not null default 'active' check (status in ('active', 'disabled')),
|
||||
channel text,
|
||||
landing_path text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, user_id),
|
||||
unique (tenant_id, code)
|
||||
);
|
||||
|
||||
comment on table public.referral_codes is
|
||||
'Tenant-scoped referral codes for sales, agents, teachers, operators, and other permitted referrers.';
|
||||
|
||||
create table if not exists public.referral_leads (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
student_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
referrer_user_id uuid references public.platform_users(id) on delete set null,
|
||||
ref_code citext,
|
||||
source text,
|
||||
first_track_id uuid references public.referral_tracks(id) on delete set null,
|
||||
bind_type text not null default 'first_touch' check (bind_type in ('first_touch', 'manual', 'imported')),
|
||||
status text not null default 'protected' check (status in ('protected', 'invalid', 'released')),
|
||||
protected_until timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
bound_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, student_user_id)
|
||||
);
|
||||
|
||||
comment on table public.referral_leads is
|
||||
'First-binding lead ownership. Once a student is bound to a referrer, normal scan/share events cannot rebind the lead.';
|
||||
|
||||
create table if not exists public.referral_team_edges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
member_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
leader_user_id uuid references public.platform_users(id) on delete set null,
|
||||
relation_type text not null default 'sales_team' check (relation_type in ('sales_team', 'agent_network', 'teacher_class')),
|
||||
status text not null default 'active' check (status in ('active', 'disabled')),
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, member_user_id, relation_type)
|
||||
);
|
||||
|
||||
create table if not exists public.referral_qrcodes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
ref_code citext not null,
|
||||
scene text not null,
|
||||
page text not null default 'pages/index/index',
|
||||
provider text not null default 'wechat-miniapp',
|
||||
qrcode_url text,
|
||||
status text not null default 'pending' check (status in ('pending', 'ready', 'failed', 'disabled')),
|
||||
error_message text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider, scene, page)
|
||||
);
|
||||
|
||||
alter table public.referral_tracks
|
||||
add column if not exists metadata jsonb not null default '{}'::jsonb,
|
||||
add column if not exists lead_id uuid references public.referral_leads(id) on delete set null;
|
||||
|
||||
alter table public.crm_webhook_queue
|
||||
add column if not exists source text,
|
||||
add column if not exists payload jsonb not null default '{}'::jsonb,
|
||||
add column if not exists idempotency_key text,
|
||||
add column if not exists target_url text;
|
||||
|
||||
alter table public.crm_webhook_log
|
||||
add column if not exists request_payload jsonb not null default '{}'::jsonb;
|
||||
|
||||
create unique index if not exists idx_crm_queue_tenant_record_id
|
||||
on public.crm_webhook_queue(tenant_id, record_id)
|
||||
where record_id is not null;
|
||||
|
||||
create unique index if not exists idx_crm_queue_tenant_idempotency
|
||||
on public.crm_webhook_queue(tenant_id, idempotency_key)
|
||||
where idempotency_key is not null;
|
||||
|
||||
create index if not exists idx_referral_codes_tenant_code
|
||||
on public.referral_codes(tenant_id, code, status);
|
||||
|
||||
create index if not exists idx_referral_leads_referrer
|
||||
on public.referral_leads(tenant_id, referrer_user_id, bound_at desc);
|
||||
|
||||
create index if not exists idx_referral_team_leader
|
||||
on public.referral_team_edges(tenant_id, leader_user_id, status);
|
||||
|
||||
create index if not exists idx_referral_tracks_lead
|
||||
on public.referral_tracks(tenant_id, lead_id, created_at desc);
|
||||
|
||||
alter table public.referral_codes enable row level security;
|
||||
alter table public.referral_leads enable row level security;
|
||||
alter table public.referral_team_edges enable row level security;
|
||||
alter table public.referral_qrcodes enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_codes;
|
||||
create policy tenant_isolation on public.referral_codes
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_leads;
|
||||
create policy tenant_isolation on public.referral_leads
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_team_edges;
|
||||
create policy tenant_isolation on public.referral_team_edges
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_qrcodes;
|
||||
create policy tenant_isolation on public.referral_qrcodes
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_codes;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_codes
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_leads;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_leads
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_team_edges;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_team_edges
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_qrcodes;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_qrcodes
|
||||
for each row execute function app.touch_updated_at();
|
||||
164
supabase/migrations/202606210007_content_import_assets.sql
Normal file
164
supabase/migrations/202606210007_content_import_assets.sql
Normal file
@@ -0,0 +1,164 @@
|
||||
alter table public.content_assets
|
||||
add column if not exists asset_type text not null default 'document',
|
||||
add column if not exists storage_provider text not null default 'external_url',
|
||||
add column if not exists bucket text,
|
||||
add column if not exists object_key text,
|
||||
add column if not exists mime_type text,
|
||||
add column if not exists file_size_bytes bigint,
|
||||
add column if not exists checksum_sha256 text,
|
||||
add column if not exists visibility text not null default 'tenant',
|
||||
add column if not exists region_id uuid references public.regions(id) on delete set null,
|
||||
add column if not exists subject_id uuid references public.subjects(id) on delete set null,
|
||||
add column if not exists category_id uuid references public.categories(id) on delete set null,
|
||||
add column if not exists node_id uuid references public.module_nodes(id) on delete set null,
|
||||
add column if not exists preview_url text,
|
||||
add column if not exists status text not null default 'active',
|
||||
add column if not exists sort_order integer not null default 0,
|
||||
add column if not exists access_rules jsonb not null default '{}'::jsonb,
|
||||
add column if not exists created_by uuid references public.platform_users(id) on delete set null,
|
||||
add column if not exists updated_by uuid references public.platform_users(id) on delete set null,
|
||||
add column if not exists source text not null default 'manual',
|
||||
add column if not exists download_count integer not null default 0;
|
||||
|
||||
update public.content_assets
|
||||
set visibility = case when is_public then 'public' else visibility end,
|
||||
storage_provider = case when cdn_url is not null and cdn_url <> '' then 'external_url' else storage_provider end
|
||||
where visibility = 'tenant' or storage_provider = 'external_url';
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_asset_type_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_asset_type_check
|
||||
check (asset_type in ('pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_storage_provider_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_storage_provider_check
|
||||
check (storage_provider in ('external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_visibility_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_visibility_check
|
||||
check (visibility in ('public', 'tenant', 'members', 'svip', 'private'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_status_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_status_check
|
||||
check (status in ('draft', 'active', 'archived'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_file_size_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_file_size_check
|
||||
check (file_size_bytes is null or file_size_bytes >= 0);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_download_count_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_download_count_check
|
||||
check (download_count >= 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create table if not exists public.content_import_jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
import_type text not null check (import_type in ('questions', 'vocabulary', 'handbook', 'scoreline', 'assets', 'videos')),
|
||||
source_format text not null default 'json' check (source_format in ('json', 'excel', 'csv', 'pocketbase', 'api')),
|
||||
status text not null default 'preview' check (status in ('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')),
|
||||
source_name text,
|
||||
source_hash text,
|
||||
target_region_id uuid references public.regions(id) on delete set null,
|
||||
target_subject_id uuid references public.subjects(id) on delete set null,
|
||||
target_category_id uuid references public.categories(id) on delete set null,
|
||||
target_node_id uuid references public.module_nodes(id) on delete set null,
|
||||
target_question_bank_id uuid references public.question_banks(id) on delete set null,
|
||||
dry_run boolean not null default true,
|
||||
total_count integer not null default 0,
|
||||
valid_count integer not null default 0,
|
||||
error_count integer not null default 0,
|
||||
warning_count integer not null default 0,
|
||||
inserted_count integer not null default 0,
|
||||
updated_count integer not null default 0,
|
||||
skipped_count integer not null default 0,
|
||||
summary jsonb not null default '{}'::jsonb,
|
||||
raw_payload jsonb not null default '[]'::jsonb,
|
||||
normalized_payload jsonb not null default '[]'::jsonb,
|
||||
error_message text,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.content_import_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
|
||||
row_no integer not null,
|
||||
external_id text,
|
||||
status text not null default 'valid' check (status in ('valid', 'invalid', 'inserted', 'updated', 'skipped', 'failed')),
|
||||
target_type text,
|
||||
target_id uuid,
|
||||
source_payload jsonb not null default '{}'::jsonb,
|
||||
normalized_payload jsonb not null default '{}'::jsonb,
|
||||
content_hash text,
|
||||
issues_count integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (job_id, row_no)
|
||||
);
|
||||
|
||||
create table if not exists public.content_import_issues (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
|
||||
item_id uuid references public.content_import_items(id) on delete cascade,
|
||||
row_no integer,
|
||||
severity text not null default 'error' check (severity in ('error', 'warning')),
|
||||
code text not null,
|
||||
field_path text,
|
||||
message text not null,
|
||||
details jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_content_assets_filters
|
||||
on public.content_assets(tenant_id, status, visibility, asset_type, region_id, subject_id, category_id, sort_order);
|
||||
create index if not exists idx_content_assets_object
|
||||
on public.content_assets(tenant_id, storage_provider, bucket, object_key);
|
||||
create index if not exists idx_content_assets_checksum
|
||||
on public.content_assets(tenant_id, checksum_sha256)
|
||||
where checksum_sha256 is not null;
|
||||
|
||||
create index if not exists idx_content_import_jobs_tenant_status
|
||||
on public.content_import_jobs(tenant_id, import_type, status, created_at desc);
|
||||
create index if not exists idx_content_import_items_job_status
|
||||
on public.content_import_items(tenant_id, job_id, status, row_no);
|
||||
create index if not exists idx_content_import_issues_job
|
||||
on public.content_import_issues(tenant_id, job_id, severity, row_no);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'content_import_jobs', 'content_import_items', 'content_import_issues'
|
||||
]
|
||||
loop
|
||||
execute format('alter table public.%I enable row level security', table_name);
|
||||
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||
execute format(
|
||||
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||
table_name
|
||||
);
|
||||
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
|
||||
end loop;
|
||||
end $$;
|
||||
47
supabase/seed.sql
Normal file
47
supabase/seed.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
insert into public.tenants (id, slug, name, status, mode)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'master',
|
||||
'升本刷题通主租户',
|
||||
'active',
|
||||
'platform_owned'
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'localhost',
|
||||
'system',
|
||||
'active',
|
||||
true
|
||||
)
|
||||
on conflict (host) do nothing;
|
||||
|
||||
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'升本刷题通',
|
||||
'刷题通',
|
||||
'多租户专升本题库 SaaS'
|
||||
)
|
||||
on conflict (tenant_id) do nothing;
|
||||
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'{"enableStore":true,"enableLeaderboard":true,"enableVocabulary":true,"enableHandbook":true,"enableScoreline":true}'::jsonb,
|
||||
'{"enableTenantManagement":true,"enableQuestionCRUD":true,"enableRevenueLedger":true,"enableMarketing":true}'::jsonb,
|
||||
'{"examDate":"","appUrl":"http://127.0.0.1:5173"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id) do nothing;
|
||||
|
||||
insert into public.tenant_auth_providers (tenant_id, provider, status, display_name, config_public)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'mock',
|
||||
'testing',
|
||||
'本地模拟短信',
|
||||
'{"channel":"local-dev"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
Reference in New Issue
Block a user