From c9c767c7bd17a898d02a7c0d418c82dee7e425f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 21:54:43 +0800 Subject: [PATCH] feat: scaffold supabase multi-tenant backend --- .dockerignore | 24 + .env.example | 42 + .gitignore | 77 + README.md | 154 + apps/api/.env.example | 4 + apps/api/Dockerfile | 34 + apps/api/package-lock.json | 729 ++ apps/api/package.json | 21 + apps/api/src/core/config.ts | 37 + apps/api/src/core/db.ts | 35 + apps/api/src/core/http.ts | 68 + apps/api/src/core/request.ts | 87 + apps/api/src/core/router.ts | 44 + apps/api/src/features/auth/index.ts | 18 + apps/api/src/features/auth/providers.ts | 52 + apps/api/src/features/auth/routes.ts | 377 + apps/api/src/features/auth/service.ts | 281 + apps/api/src/features/catalog/assets.ts | 202 + apps/api/src/features/catalog/index.ts | 47 + apps/api/src/features/catalog/routes.ts | 580 + apps/api/src/features/commerce/index.ts | 18 + apps/api/src/features/commerce/routes.ts | 385 + apps/api/src/features/commerce/service.ts | 80 + apps/api/src/features/health/index.ts | 6 + apps/api/src/features/health/routes.ts | 11 + apps/api/src/features/learning/index.ts | 28 + apps/api/src/features/learning/routes.ts | 453 + apps/api/src/features/platform-admin/index.ts | 34 + .../api/src/features/platform-admin/routes.ts | 871 ++ .../src/features/platform-admin/service.ts | 118 + apps/api/src/features/profile/index.ts | 7 + apps/api/src/features/profile/routes.ts | 259 + apps/api/src/features/referral/index.ts | 34 + apps/api/src/features/referral/routes.ts | 920 ++ apps/api/src/features/scoreline/index.ts | 18 + apps/api/src/features/scoreline/routes.ts | 164 + apps/api/src/features/tenant-admin/auth.ts | 130 + apps/api/src/features/tenant-admin/index.ts | 64 + apps/api/src/features/tenant-admin/routes.ts | 1466 +++ .../api/src/features/tenant-content/assets.ts | 354 + apps/api/src/features/tenant-content/auth.ts | 43 + .../src/features/tenant-content/imports.ts | 951 ++ apps/api/src/features/tenant-content/index.ts | 72 + .../api/src/features/tenant-content/routes.ts | 820 ++ apps/api/src/features/tenant-content/utils.ts | 32 + apps/api/src/features/tenant/index.ts | 6 + apps/api/src/features/tenant/routes.ts | 108 + apps/api/src/features/video/index.ts | 8 + apps/api/src/features/video/routes.ts | 111 + apps/api/src/server.ts | 48 + apps/api/tsconfig.json | 14 + docker-compose.api.yml | 25 + docs/pb_schema.json | 10917 ++++++++++++++++ docs/refactor/README.md | 28 + docs/refactor/api-structure.md | 70 + docs/refactor/architecture.md | 97 + docs/refactor/auth-payment-provider-plan.md | 94 + docs/refactor/backend-progress.md | 177 + docs/refactor/blueprint-coverage.md | 43 + docs/refactor/data-governance.md | 65 + docs/refactor/implementation-status.md | 240 + docs/refactor/local-supabase.md | 127 + .../pocketbase-to-supabase-mapping.md | 89 + package-lock.json | 5350 ++++++++ package.json | 80 + packages/config/package-lock.json | 12 + packages/config/package.json | 8 + packages/config/src/index.js | 43 + packages/config/src/index.ts | 45 + packages/db/package-lock.json | 194 + packages/db/package.json | 14 + packages/db/src/index.js | 18 + packages/db/src/index.ts | 28 + packages/domain/package-lock.json | 12 + packages/domain/package.json | 8 + packages/domain/src/index.js | 37 + packages/domain/src/index.ts | 39 + scripts/api-integration-test.js | 1138 ++ scripts/import-pocketbase/.env.example | 4 + scripts/import-pocketbase/package-lock.json | 729 ++ scripts/import-pocketbase/package.json | 22 + .../import-pocketbase/src/analyze-schema.ts | 30 + scripts/import-pocketbase/src/db.ts | 22 + scripts/import-pocketbase/src/env.ts | 5 + scripts/import-pocketbase/src/import-json.ts | 2926 +++++ scripts/import-pocketbase/src/pb-schema.ts | 38 + scripts/import-pocketbase/src/risk-report.ts | 80 + .../import-pocketbase/src/validate-import.ts | 354 + scripts/import-pocketbase/tsconfig.json | 14 + scripts/smoke-seed.js | 741 ++ supabase/config.toml | 55 + .../202606210001_core_multitenant_schema.sql | 995 ++ ...606210002_commercial_domain_extensions.sql | 257 + ...003_learning_content_import_extensions.sql | 180 + ...2606210004_auth_china_login_extensions.sql | 122 + .../202606210005_platform_admin_billing.sql | 181 + .../202606210006_growth_referral_crm.sql | 150 + .../202606210007_content_import_assets.sql | 164 + supabase/seed.sql | 47 + 99 files changed, 36660 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/package-lock.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/core/config.ts create mode 100644 apps/api/src/core/db.ts create mode 100644 apps/api/src/core/http.ts create mode 100644 apps/api/src/core/request.ts create mode 100644 apps/api/src/core/router.ts create mode 100644 apps/api/src/features/auth/index.ts create mode 100644 apps/api/src/features/auth/providers.ts create mode 100644 apps/api/src/features/auth/routes.ts create mode 100644 apps/api/src/features/auth/service.ts create mode 100644 apps/api/src/features/catalog/assets.ts create mode 100644 apps/api/src/features/catalog/index.ts create mode 100644 apps/api/src/features/catalog/routes.ts create mode 100644 apps/api/src/features/commerce/index.ts create mode 100644 apps/api/src/features/commerce/routes.ts create mode 100644 apps/api/src/features/commerce/service.ts create mode 100644 apps/api/src/features/health/index.ts create mode 100644 apps/api/src/features/health/routes.ts create mode 100644 apps/api/src/features/learning/index.ts create mode 100644 apps/api/src/features/learning/routes.ts create mode 100644 apps/api/src/features/platform-admin/index.ts create mode 100644 apps/api/src/features/platform-admin/routes.ts create mode 100644 apps/api/src/features/platform-admin/service.ts create mode 100644 apps/api/src/features/profile/index.ts create mode 100644 apps/api/src/features/profile/routes.ts create mode 100644 apps/api/src/features/referral/index.ts create mode 100644 apps/api/src/features/referral/routes.ts create mode 100644 apps/api/src/features/scoreline/index.ts create mode 100644 apps/api/src/features/scoreline/routes.ts create mode 100644 apps/api/src/features/tenant-admin/auth.ts create mode 100644 apps/api/src/features/tenant-admin/index.ts create mode 100644 apps/api/src/features/tenant-admin/routes.ts create mode 100644 apps/api/src/features/tenant-content/assets.ts create mode 100644 apps/api/src/features/tenant-content/auth.ts create mode 100644 apps/api/src/features/tenant-content/imports.ts create mode 100644 apps/api/src/features/tenant-content/index.ts create mode 100644 apps/api/src/features/tenant-content/routes.ts create mode 100644 apps/api/src/features/tenant-content/utils.ts create mode 100644 apps/api/src/features/tenant/index.ts create mode 100644 apps/api/src/features/tenant/routes.ts create mode 100644 apps/api/src/features/video/index.ts create mode 100644 apps/api/src/features/video/routes.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/tsconfig.json create mode 100644 docker-compose.api.yml create mode 100644 docs/pb_schema.json create mode 100644 docs/refactor/README.md create mode 100644 docs/refactor/api-structure.md create mode 100644 docs/refactor/architecture.md create mode 100644 docs/refactor/auth-payment-provider-plan.md create mode 100644 docs/refactor/backend-progress.md create mode 100644 docs/refactor/blueprint-coverage.md create mode 100644 docs/refactor/data-governance.md create mode 100644 docs/refactor/implementation-status.md create mode 100644 docs/refactor/local-supabase.md create mode 100644 docs/refactor/pocketbase-to-supabase-mapping.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/config/package-lock.json create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.js create mode 100644 packages/config/src/index.ts create mode 100644 packages/db/package-lock.json create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.js create mode 100644 packages/db/src/index.ts create mode 100644 packages/domain/package-lock.json create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/index.js create mode 100644 packages/domain/src/index.ts create mode 100644 scripts/api-integration-test.js create mode 100644 scripts/import-pocketbase/.env.example create mode 100644 scripts/import-pocketbase/package-lock.json create mode 100644 scripts/import-pocketbase/package.json create mode 100644 scripts/import-pocketbase/src/analyze-schema.ts create mode 100644 scripts/import-pocketbase/src/db.ts create mode 100644 scripts/import-pocketbase/src/env.ts create mode 100644 scripts/import-pocketbase/src/import-json.ts create mode 100644 scripts/import-pocketbase/src/pb-schema.ts create mode 100644 scripts/import-pocketbase/src/risk-report.ts create mode 100644 scripts/import-pocketbase/src/validate-import.ts create mode 100644 scripts/import-pocketbase/tsconfig.json create mode 100644 scripts/smoke-seed.js create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/202606210001_core_multitenant_schema.sql create mode 100644 supabase/migrations/202606210002_commercial_domain_extensions.sql create mode 100644 supabase/migrations/202606210003_learning_content_import_extensions.sql create mode 100644 supabase/migrations/202606210004_auth_china_login_extensions.sql create mode 100644 supabase/migrations/202606210005_platform_admin_billing.sql create mode 100644 supabase/migrations/202606210006_growth_referral_crm.sql create mode 100644 supabase/migrations/202606210007_content_import_assets.sql create mode 100644 supabase/seed.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..942fe75e --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2e56da0a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e5affc5e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..8d916f5a --- /dev/null +++ b/README.md @@ -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. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000..b6c0b6e9 --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 00000000..b92d6fff --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json new file mode 100644 index 00000000..ca554fe9 --- /dev/null +++ b/apps/api/package-lock.json @@ -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" + } + } + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..a5fb6c5c --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts new file mode 100644 index 00000000..48b526ff --- /dev/null +++ b/apps/api/src/core/config.ts @@ -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, +}; diff --git a/apps/api/src/core/db.ts b/apps/api/src/core/db.ts new file mode 100644 index 00000000..cb9e0d2d --- /dev/null +++ b/apps/api/src/core/db.ts @@ -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(sql: string, params: unknown[] = []): Promise { + return runQuery(pool, sql, params); +} + +export async function queryOne(sql: string, params: unknown[] = []): Promise { + return runQueryOne(pool, sql, params); +} + +export async function closePool() { + await pool.end(); +} + +export async function transaction(callback: (client: pg.PoolClient) => Promise) { + 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(); + } +} diff --git a/apps/api/src/core/http.ts b/apps/api/src/core/http.ts new file mode 100644 index 00000000..8fe3aefe --- /dev/null +++ b/apps/api/src/core/http.ts @@ -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; + +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', + }, + }; +} diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts new file mode 100644 index 00000000..ee8d2dec --- /dev/null +++ b/apps/api/src/core/request.ts @@ -0,0 +1,87 @@ +import { config } from './config.js'; +import { getHeader, HttpError, type RequestContext } from './http.js'; + +export type JsonObject = Record; + +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 { + 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'); + } +} diff --git a/apps/api/src/core/router.ts b/apps/api/src/core/router.ts new file mode 100644 index 00000000..efc84065 --- /dev/null +++ b/apps/api/src/core/router.ts @@ -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(); + + 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, +]; diff --git a/apps/api/src/features/auth/index.ts b/apps/api/src/features/auth/index.ts new file mode 100644 index 00000000..ca68e16c --- /dev/null +++ b/apps/api/src/features/auth/index.ts @@ -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], +]; diff --git a/apps/api/src/features/auth/providers.ts b/apps/api/src/features/auth/providers.ts new file mode 100644 index 00000000..55af2eba --- /dev/null +++ b/apps/api/src/features/auth/providers.ts @@ -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; +} + +export interface SmsSendResult { + provider: SmsProviderName; + status: 'sent' | 'mocked'; + providerMessageId?: string; + raw?: Record; +} + +export interface SmsProvider { + name: SmsProviderName; + send(input: SmsSendInput): Promise; +} + +class MockSmsProvider implements SmsProvider { + readonly name = 'mock' as const; + + async send(): Promise { + return { + provider: this.name, + status: 'mocked', + raw: { localOnly: true }, + }; + } +} + +class NotConfiguredSmsProvider implements SmsProvider { + constructor(readonly name: SmsProviderName) {} + + async send(): Promise { + 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']; +} diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts new file mode 100644 index 00000000..8f17e0be --- /dev/null +++ b/apps/api/src/features/auth/routes.ts @@ -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) : {}; +} + +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( + ` + 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(async client => { + const codeResult = await client.query( + ` + 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', + ); +} diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts new file mode 100644 index 00000000..21f74235 --- /dev/null +++ b/apps/api/src/features/auth/service.ts @@ -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; + }, +): Promise { + 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( + ` + 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( + ` + 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( + ` + 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; + }, +) { + 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 || {}), + ], + ); +} diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts new file mode 100644 index 00000000..9d1d78b9 --- /dev/null +++ b/apps/api/src/features/catalog/assets.ts @@ -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( + ` + 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), + }; +} diff --git a/apps/api/src/features/catalog/index.ts b/apps/api/src/features/catalog/index.ts new file mode 100644 index 00000000..6c9cc2f6 --- /dev/null +++ b/apps/api/src/features/catalog/index.ts @@ -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], +]; diff --git a/apps/api/src/features/catalog/routes.ts b/apps/api/src/features/catalog/routes.ts new file mode 100644 index 00000000..8d58c59e --- /dev/null +++ b/apps/api/src/features/catalog/routes.ts @@ -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 }; +} diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts new file mode 100644 index 00000000..3c030ef1 --- /dev/null +++ b/apps/api/src/features/commerce/index.ts @@ -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], +]; diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts new file mode 100644 index 00000000..c04b23ef --- /dev/null +++ b/apps/api/src/features/commerce/routes.ts @@ -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; + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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 }; +} diff --git a/apps/api/src/features/commerce/service.ts b/apps/api/src/features/commerce/service.ts new file mode 100644 index 00000000..6e0470e8 --- /dev/null +++ b/apps/api/src/features/commerce/service.ts @@ -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; +} + +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}`; +} diff --git a/apps/api/src/features/health/index.ts b/apps/api/src/features/health/index.ts new file mode 100644 index 00000000..4b2a188b --- /dev/null +++ b/apps/api/src/features/health/index.ts @@ -0,0 +1,6 @@ +import type { RouteDefinition } from '../../core/router.js'; +import { healthRoute } from './routes.js'; + +export const healthRoutes: RouteDefinition[] = [ + ['GET', '/health', async () => healthRoute()], +]; diff --git a/apps/api/src/features/health/routes.ts b/apps/api/src/features/health/routes.ts new file mode 100644 index 00000000..2444d5be --- /dev/null +++ b/apps/api/src/features/health/routes.ts @@ -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(), + }; +} diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts new file mode 100644 index 00000000..de9faf4b --- /dev/null +++ b/apps/api/src/features/learning/index.ts @@ -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], +]; diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts new file mode 100644 index 00000000..e7d30451 --- /dev/null +++ b/apps/api/src/features/learning/routes.ts @@ -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(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( + ` + 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 }; +} diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts new file mode 100644 index 00000000..9c926fc5 --- /dev/null +++ b/apps/api/src/features/platform-admin/index.ts @@ -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], +]; diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts new file mode 100644 index 00000000..4f746755 --- /dev/null +++ b/apps/api/src/features/platform-admin/routes.ts @@ -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) : {}; + 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; + items: ReturnType; +} + +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) : {}, + }); + + 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 }; +} diff --git a/apps/api/src/features/platform-admin/service.ts b/apps/api/src/features/platform-admin/service.ts new file mode 100644 index 00000000..90d5186a --- /dev/null +++ b/apps/api/src/features/platform-admin/service.ts @@ -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; +} + +export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] { + if (!Array.isArray(items)) return []; + + return items + .map(item => (item && typeof item === 'object' ? (item as Record) : null)) + .filter((item): item is Record => !!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) : {}, + })) + .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]; +} diff --git a/apps/api/src/features/profile/index.ts b/apps/api/src/features/profile/index.ts new file mode 100644 index 00000000..57ce5445 --- /dev/null +++ b/apps/api/src/features/profile/index.ts @@ -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], +]; diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts new file mode 100644 index 00000000..d4e4d36e --- /dev/null +++ b/apps/api/src/features/profile/routes.ts @@ -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; + +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( + ` + 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 }; +} diff --git a/apps/api/src/features/referral/index.ts b/apps/api/src/features/referral/index.ts new file mode 100644 index 00000000..eac12eb8 --- /dev/null +++ b/apps/api/src/features/referral/index.ts @@ -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], +]; diff --git a/apps/api/src/features/referral/routes.ts b/apps/api/src/features/referral/routes.ts new file mode 100644 index 00000000..5808b40b --- /dev/null +++ b/apps/api/src/features/referral/routes.ts @@ -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; + +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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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 = {}) { + 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; + }, +) { + 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; + }>( + ` + 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; + }, +) { + 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) => ({ + ...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) => ({ + ...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 }; +} diff --git a/apps/api/src/features/scoreline/index.ts b/apps/api/src/features/scoreline/index.ts new file mode 100644 index 00000000..1bf2eb16 --- /dev/null +++ b/apps/api/src/features/scoreline/index.ts @@ -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], +]; diff --git a/apps/api/src/features/scoreline/routes.ts b/apps/api/src/features/scoreline/routes.ts new file mode 100644 index 00000000..27464c07 --- /dev/null +++ b/apps/api/src/features/scoreline/routes.ts @@ -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) }; +} diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts new file mode 100644 index 00000000..473e7d30 --- /dev/null +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -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 = { + 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; +} + +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, 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 { + const tenantId = tenantIdFrom(ctx); + const userId = userIdFrom(ctx); + + const membership = await queryOne<{ role: string; permissions: Record }>( + ` + 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 || {} }; +} diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts new file mode 100644 index 00000000..e69392c0 --- /dev/null +++ b/apps/api/src/features/tenant-admin/index.ts @@ -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], +]; diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts new file mode 100644 index 00000000..5cf98d5c --- /dev/null +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -0,0 +1,1466 @@ +import { randomBytes } from 'node:crypto'; +import type pg from 'pg'; +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 { + requireTenantAdmin, + requireTenantPermission, + tenantPermissionCatalog, + type TenantAdminAuth, +} from './auth.js'; + +type JsonBody = Record; +type SecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system'; + +const SECRET_SCOPES = new Set(['payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system']); +const PAYMENT_MODES = ['platform_collect', 'tenant_collect', 'service_provider']; +const PAYMENT_STATUSES = ['active', 'disabled', 'pending']; +const AUTH_STATUSES = ['active', 'disabled', 'testing']; +const DISCOUNT_TYPES = ['percent', 'fixed']; +const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student']; +const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled']; + +function jsonBodyValue(value: unknown) { + return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {}); +} + +function jsonArrayValue(value: unknown) { + return JSON.stringify(Array.isArray(value) ? value : []); +} + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function boolValue(value: unknown, fallback: boolean) { + return typeof value === 'boolean' ? value : fallback; +} + +function intValue(value: unknown, fallback: number) { + const numberValue = Number(value ?? fallback); + return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback; +} + +function numberValue(value: unknown, fallback: number | null = null) { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) ? parsed : fallback; +} + +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; +} + +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 objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function assertPublicConfigHasNoSecrets(value: unknown, path = 'configPublic') { + if (!value || typeof value !== 'object') return; + + if (Array.isArray(value)) { + value.forEach((item, index) => assertPublicConfigHasNoSecrets(item, `${path}[${index}]`)); + return; + } + + for (const [key, child] of Object.entries(value as Record)) { + const normalized = key.toLowerCase().replace(/[-_\s]/g, ''); + const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref'); + const sensitiveKey = + normalized.includes('secret') || + normalized.includes('password') || + normalized.includes('token') || + normalized.includes('privatekey') || + normalized.includes('apikey') || + normalized.includes('apiv3key') || + normalized.includes('mchkey') || + normalized.includes('signkey') || + normalized.includes('aeskey') || + normalized.includes('partnerkey'); + + if (sensitiveKey && !allowedSecretRef) { + throw new HttpError( + 400, + `${path}.${key} looks sensitive. Store secrets in app_private.tenant_secrets and expose only secretRef.`, + 'PUBLIC_CONFIG_SECRET_REJECTED', + ); + } + + assertPublicConfigHasNoSecrets(child, `${path}.${key}`); + } +} + +function publicJsonValue(value: unknown) { + const publicConfig = objectValue(value); + assertPublicConfigHasNoSecrets(publicConfig); + return JSON.stringify(publicConfig); +} + +function secretRef(scope: SecretScope, secretKey: string) { + return `app_private.tenant_secrets:${scope}:${secretKey}`; +} + +function parseSecretScope(value: unknown, fallback: SecretScope): SecretScope { + const candidate = (nullableString(value) || fallback) as SecretScope; + if (!SECRET_SCOPES.has(candidate)) { + throw new HttpError(400, `Invalid secret scope: ${candidate}`, 'INVALID_SECRET_SCOPE'); + } + return candidate; +} + +function parseSecretPayload( + body: JsonBody, + fallbackScope: SecretScope, + fallbackSecretKey: string, + fallbackProvider: string, +) { + const raw = body.secret; + if (!raw) return null; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new HttpError(400, 'secret must be an object', 'INVALID_SECRET_PAYLOAD'); + } + + const secret = raw as JsonBody; + const scope = parseSecretScope(secret.secretScope || body.secretScope, fallbackScope); + const secretKey = nullableString(secret.secretKey) || fallbackSecretKey; + const secretValue = nullableString(secret.secretValue); + const secretJson = objectValue(secret.secretJson); + const provider = nullableString(secret.provider) || fallbackProvider; + + if (!secretValue && Object.keys(secretJson).length === 0) { + throw new HttpError(400, 'secret.secretValue or secret.secretJson is required', 'SECRET_VALUE_REQUIRED'); + } + + return { scope, secretKey, secretValue, secretJson, provider }; +} + +async function upsertTenantSecret( + client: pg.PoolClient, + auth: TenantAdminAuth, + payload: { + scope: SecretScope; + secretKey: string; + secretValue: string | null; + secretJson: Record; + provider: string | null; + }, +) { + const result = await client.query( + ` + insert into app_private.tenant_secrets ( + tenant_id, secret_scope, secret_key, secret_value, secret_json, provider, last_rotated_at + ) + values ($1, $2, $3, $4, $5::jsonb, $6, now()) + on conflict (tenant_id, secret_scope, secret_key) + do update set secret_value = excluded.secret_value, + secret_json = excluded.secret_json, + provider = excluded.provider, + last_rotated_at = now(), + updated_at = now() + returning id, secret_scope as "secretScope", secret_key as "secretKey", provider, + (secret_value is not null and secret_value <> '') as "hasSecretValue", + (secret_json <> '{}'::jsonb) as "hasSecretJson", + last_rotated_at as "lastRotatedAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + payload.scope, + payload.secretKey, + payload.secretValue, + JSON.stringify(payload.secretJson), + payload.provider, + ], + ); + + return { + ...result.rows[0], + secretRef: secretRef(payload.scope, payload.secretKey), + }; +} + +async function recordAudit( + client: pg.PoolClient, + auth: TenantAdminAuth, + action: string, + targetType: string, + targetId: string | null, + details: Record = {}, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, $4, $5, $6::jsonb) + `, + [auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)], + ); +} + +function codeValue(body: JsonBody) { + return requiredString(body, 'code').replace(/\s+/g, '').toUpperCase(); +} + +function randomCode(prefix = '') { + return `${prefix}${randomBytes(5).toString('hex').toUpperCase()}`; +} + +function permissionValue(value: unknown) { + const source = objectValue(value); + const permissions: Record = {}; + for (const [key, raw] of Object.entries(source)) { + if (typeof raw !== 'boolean') { + throw new HttpError(400, `Permission ${key} must be boolean`, 'INVALID_PERMISSION_VALUE'); + } + if (key !== '*' && !/^[a-z][a-z0-9]*(?::[a-z0-9*]+)+$/i.test(key)) { + throw new HttpError(400, `Invalid permission key: ${key}`, 'INVALID_PERMISSION_KEY'); + } + permissions[key] = raw; + } + return permissions; +} + +function requiredMemberRole(value: unknown) { + return optionalChoice(value, TENANT_MEMBER_ROLES, 'student'); +} + +function ensureCanGrantRole(auth: TenantAdminAuth, role: string, permissions: Record) { + if (auth.role === 'tenant_owner') return; + if (role === 'tenant_owner' || role === 'tenant_admin' || permissions['*'] === true) { + throw new HttpError(403, 'Only tenant owner can grant owner/admin level permissions', 'TENANT_OWNER_REQUIRED'); + } +} + +async function ensureOwnerRemains( + client: pg.PoolClient, + tenantId: string, + membershipId: string | null, + nextRole: string, + nextStatus: string, +) { + if (!membershipId) return; + + const current = await client.query<{ role: string; status: string }>( + 'select role, status from public.tenant_memberships where tenant_id = $1 and id = $2 limit 1', + [tenantId, membershipId], + ); + if (current.rows[0]?.role !== 'tenant_owner' || current.rows[0]?.status !== 'active') return; + if (nextRole === 'tenant_owner' && nextStatus === 'active') return; + + const owners = await client.query<{ count: string }>( + ` + select count(*)::text as count + from public.tenant_memberships + where tenant_id = $1 + and role = 'tenant_owner' + and status = 'active' + and id <> $2 + `, + [tenantId, membershipId], + ); + if (Number(owners.rows[0]?.count || 0) <= 0) { + throw new HttpError(400, 'At least one active tenant owner is required', 'LAST_TENANT_OWNER_REQUIRED'); + } +} + +async function resolveOrCreateMemberUser(client: pg.PoolClient, body: JsonBody) { + const userId = nullableString(body.userId); + if (userId) { + const existing = await client.query<{ id: string }>('select id from public.platform_users where id = $1 limit 1', [userId]); + if (!existing.rows[0]) throw new HttpError(404, 'User not found', 'USER_NOT_FOUND'); + await client.query( + ` + update public.platform_users + set username = coalesce($2, username), + email = coalesce($3::citext, email), + phone = coalesce($4, phone), + name = coalesce($5, name), + primary_role = coalesce($6, primary_role), + updated_at = now() + where id = $1 + `, + [ + userId, + nullableString(body.username), + nullableString(body.email), + nullableString(body.phone), + nullableString(body.name), + nullableString(body.primaryRole), + ], + ); + return existing.rows[0].id; + } + + const phone = nullableString(body.phone); + const email = nullableString(body.email); + const username = nullableString(body.username); + const name = nullableString(body.name); + if (!phone && !email && !username && !name) { + throw new HttpError(400, 'userId, phone, email, username, or name is required', 'MEMBER_USER_REQUIRED'); + } + + const found = await client.query<{ id: string }>( + ` + select id + from public.platform_users + where ($1::text is not null and phone = $1) + or ($2::citext is not null and email = $2::citext) + or ($3::text is not null and username = $3) + order by created_at asc + limit 1 + `, + [phone, email, username], + ); + if (found.rows[0]) return found.rows[0].id; + + const created = await client.query<{ id: string }>( + ` + insert into public.platform_users (username, email, phone, name, primary_role, raw_profile) + values ($1, $2::citext, $3, $4, $5, '{"source":"tenant-admin"}'::jsonb) + returning id + `, + [ + username || phone || email, + email, + phone, + name || username || phone || email, + optionalChoice(body.primaryRole, ['student', 'teacher', 'sales', 'agent', 'tenant_operator'], 'student'), + ], + ); + return created.rows[0].id; +} + +export async function tenantPermissionsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + return { + current: { + userId: auth.userId, + role: auth.role, + permissions: auth.permissions, + }, + ...tenantPermissionCatalog(), + }; +} + +export async function tenantOverviewRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:overview:read'); + + const item = await queryOne( + ` + select t.id, t.slug, t.name, t.legal_name as "legalName", t.status, t.mode, + t.billing_status as "billingStatus", t.metadata, + b.brand_name as "brandName", b.short_name as "shortName", b.slogan, + b.org_name as "orgName", b.logo_url as "logoUrl", b.favicon_url as "faviconUrl", + b.service_wechat as "serviceWechat", b.service_account_name as "serviceAccountName", + coalesce(b.theme, '{}'::jsonb) as theme, + coalesce(b.public_assets, '{}'::jsonb) as "publicAssets", + coalesce(s.feature_flags, '{}'::jsonb) as "featureFlags", + coalesce(s.admin_feature_flags, '{}'::jsonb) as "adminFeatureFlags", + coalesce(s.public_config, '{}'::jsonb) as "publicConfig", + t.created_at as "createdAt", t.updated_at as "updatedAt" + 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.id = $1 + limit 1 + `, + [auth.tenantId], + ); + + if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND'); + return { item }; +} + +export async function updateTenantBrandingRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:branding:write'); + const body = await readJsonBody(ctx); + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.tenant_branding ( + tenant_id, brand_name, short_name, slogan, org_name, logo_url, favicon_url, + service_wechat, service_account_name, theme, public_assets + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb) + on conflict (tenant_id) + do update set brand_name = excluded.brand_name, + short_name = excluded.short_name, + slogan = excluded.slogan, + org_name = excluded.org_name, + logo_url = excluded.logo_url, + favicon_url = excluded.favicon_url, + service_wechat = excluded.service_wechat, + service_account_name = excluded.service_account_name, + theme = excluded.theme, + public_assets = excluded.public_assets, + updated_at = now() + returning tenant_id as "tenantId", brand_name as "brandName", short_name as "shortName", + slogan, org_name as "orgName", logo_url as "logoUrl", favicon_url as "faviconUrl", + service_wechat as "serviceWechat", service_account_name as "serviceAccountName", + theme, public_assets as "publicAssets", updated_at as "updatedAt" + `, + [ + auth.tenantId, + requiredString(body, 'brandName'), + optionalString(body, 'shortName') || null, + optionalString(body, 'slogan') || null, + optionalString(body, 'orgName') || null, + optionalString(body, 'logoUrl') || null, + optionalString(body, 'faviconUrl') || null, + optionalString(body, 'serviceWechat') || null, + optionalString(body, 'serviceAccountName') || null, + jsonBodyValue(body.theme), + jsonBodyValue(body.publicAssets), + ], + ); + await recordAudit(client, auth, 'tenant.branding.updated', 'tenant_branding', auth.tenantId); + return result.rows[0]; + }); + + return { item }; +} + +export async function updateTenantSettingsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:settings:write'); + const body = await readJsonBody(ctx); + assertPublicConfigHasNoSecrets(body.publicConfig, 'publicConfig'); + + const item = await transaction(async client => { + const result = 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) + 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() + returning tenant_id as "tenantId", feature_flags as "featureFlags", + admin_feature_flags as "adminFeatureFlags", + public_config as "publicConfig", updated_at as "updatedAt" + `, + [ + auth.tenantId, + jsonBodyValue(body.featureFlags), + jsonBodyValue(body.adminFeatureFlags), + jsonBodyValue(body.publicConfig), + ], + ); + await recordAudit(client, auth, 'tenant.settings.updated', 'tenant_settings', auth.tenantId); + return result.rows[0]; + }); + + return { item }; +} + +export async function tenantDomainsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:domains:read'); + const items = await query( + ` + select id, host, domain_type as "domainType", status, is_primary as "isPrimary", + verification_token as "verificationToken", verified_at as "verifiedAt", + created_at as "createdAt", updated_at as "updatedAt" + from public.tenant_domains + where tenant_id = $1 + order by is_primary desc, created_at asc + `, + [auth.tenantId], + ); + + return { items }; +} + +export async function createTenantDomainRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:domains:write'); + const body = await readJsonBody(ctx); + const host = requiredString(body, 'host').toLowerCase().replace(/^https?:\/\//, '').split('/')[0]; + const domainType = optionalChoice(body.domainType, ['system', 'custom', 'miniapp'], 'custom'); + const isPrimary = body.isPrimary === true; + const verificationToken = `tenant-${auth.tenantId.slice(0, 8)}-${Math.random().toString(36).slice(2, 10)}`; + + const item = await transaction(async client => { + if (isPrimary) { + await client.query('update public.tenant_domains set is_primary = false where tenant_id = $1', [auth.tenantId]); + } + + const result = await client.query( + ` + insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary, verification_token) + values ($1, $2, $3, 'pending', $4, $5) + returning id, host, domain_type as "domainType", status, is_primary as "isPrimary", + verification_token as "verificationToken", created_at as "createdAt" + `, + [auth.tenantId, host, domainType, isPrimary, verificationToken], + ); + + await recordAudit(client, auth, 'tenant.domain.created', 'tenant_domains', result.rows[0].id, { host }); + return result.rows[0]; + }); + + return { item }; +} + +export async function paymentAccountsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:payment:read'); + const items = await query( + ` + select id, provider, mode, display_name as "displayName", status, + config_public as "configPublic", created_at as "createdAt", updated_at as "updatedAt" + from public.tenant_payment_accounts + where tenant_id = $1 + order by created_at asc + `, + [auth.tenantId], + ); + + return { items }; +} + +export async function upsertPaymentAccountRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:payment:write'); + const body = await readJsonBody(ctx); + const provider = requiredString(body, 'provider'); + const mode = optionalChoice(body.mode, PAYMENT_MODES, 'platform_collect'); + const secretPayload = parseSecretPayload(body, 'payment', provider, provider); + const configPublic = objectValue(body.configPublic); + + const item = await transaction(async client => { + const secret = secretPayload ? await upsertTenantSecret(client, auth, secretPayload) : null; + if (secret) configPublic.secretRef = secret.secretRef; + + const result = await client.query( + ` + insert into public.tenant_payment_accounts (tenant_id, provider, mode, display_name, status, config_public) + values ($1, $2, $3, $4, $5, $6::jsonb) + on conflict (tenant_id, provider) + do update set mode = excluded.mode, + display_name = excluded.display_name, + status = excluded.status, + config_public = excluded.config_public, + updated_at = now() + returning id, provider, mode, display_name as "displayName", status, + config_public as "configPublic", updated_at as "updatedAt" + `, + [ + auth.tenantId, + provider, + mode, + optionalString(body, 'displayName') || null, + optionalStatus(body.status, PAYMENT_STATUSES, 'disabled'), + publicJsonValue(configPublic), + ], + ); + + await recordAudit(client, auth, 'tenant.payment_account.upserted', 'tenant_payment_accounts', result.rows[0].id, { + provider, + mode, + secretRotated: Boolean(secret), + }); + + return { ...result.rows[0], secret }; + }); + + return { item }; +} + +export async function authProvidersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:auth:read'); + const items = await query( + ` + select id, provider, status, display_name as "displayName", + config_public as "configPublic", created_at as "createdAt", updated_at as "updatedAt" + from public.tenant_auth_providers + where tenant_id = $1 + order by created_at asc + `, + [auth.tenantId], + ); + + return { items }; +} + +export async function upsertAuthProviderRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:auth:write'); + const body = await readJsonBody(ctx); + const provider = requiredString(body, 'provider'); + const fallbackScope = provider.toLowerCase().includes('sms') ? 'sms' : 'oauth'; + const secretPayload = parseSecretPayload(body, fallbackScope, provider, provider); + const configPublic = objectValue(body.configPublic); + + const item = await transaction(async client => { + const secret = secretPayload ? await upsertTenantSecret(client, auth, secretPayload) : null; + if (secret) configPublic.secretRef = secret.secretRef; + + const result = await client.query( + ` + insert into public.tenant_auth_providers (tenant_id, provider, status, display_name, config_public) + values ($1, $2, $3, $4, $5::jsonb) + on conflict (tenant_id, provider) + do update set status = excluded.status, + display_name = excluded.display_name, + config_public = excluded.config_public, + updated_at = now() + returning id, provider, status, display_name as "displayName", + config_public as "configPublic", updated_at as "updatedAt" + `, + [ + auth.tenantId, + provider, + optionalStatus(body.status, AUTH_STATUSES, 'disabled'), + optionalString(body, 'displayName') || null, + publicJsonValue(configPublic), + ], + ); + + await recordAudit(client, auth, 'tenant.auth_provider.upserted', 'tenant_auth_providers', result.rows[0].id, { + provider, + secretRotated: Boolean(secret), + }); + + return { ...result.rows[0], secret }; + }); + + return { item }; +} + +export async function tenantSecretsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:secrets:read'); + const scope = stringParam(ctx, 'scope'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (scope) { + if (!SECRET_SCOPES.has(scope as SecretScope)) { + throw new HttpError(400, `Invalid secret scope: ${scope}`, 'INVALID_SECRET_SCOPE'); + } + params.push(scope); + filters.push(`secret_scope = $${params.length}`); + } + + const items = await query<{ + id: string; + secretScope: SecretScope; + secretKey: string; + provider: string | null; + hasSecretValue: boolean; + hasSecretJson: boolean; + lastRotatedAt: string | null; + createdAt: string; + updatedAt: string; + }>( + ` + select id, secret_scope as "secretScope", secret_key as "secretKey", provider, + (secret_value is not null and secret_value <> '') as "hasSecretValue", + (secret_json <> '{}'::jsonb) as "hasSecretJson", + last_rotated_at as "lastRotatedAt", created_at as "createdAt", updated_at as "updatedAt" + from app_private.tenant_secrets + where ${filters.join(' and ')} + order by secret_scope asc, secret_key asc + `, + params, + ); + + return { + items: items.map(item => ({ + ...item, + secretRef: secretRef(item.secretScope, item.secretKey), + })), + }; +} + +export async function upsertTenantSecretRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:secrets:write'); + const body = await readJsonBody(ctx); + const scope = parseSecretScope(body.secretScope, 'system'); + const secretKey = requiredString(body, 'secretKey'); + const secretValue = nullableString(body.secretValue); + const secretJson = objectValue(body.secretJson); + if (!secretValue && Object.keys(secretJson).length === 0) { + throw new HttpError(400, 'secretValue or secretJson is required', 'SECRET_VALUE_REQUIRED'); + } + + const item = await transaction(async client => { + const secret = await upsertTenantSecret(client, auth, { + scope, + secretKey, + secretValue, + secretJson, + provider: nullableString(body.provider), + }); + await recordAudit(client, auth, 'tenant.secret.upserted', 'tenant_secrets', secret.id, { + secretScope: scope, + secretKey, + }); + return secret; + }); + + return { item }; +} + +export async function bannersAdminRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:read'); + const regionId = stringParam(ctx, 'regionId'); + const limit = intParam(ctx, 'limit', 100, 500); + const includeInactive = ctx.url.searchParams.get('includeInactive') === 'true'; + 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 tenant_id = $1 + and ($2::uuid is null or region_id = $2::uuid) + and ($3::boolean or is_active = true) + order by sort_order asc, created_at desc + limit $4 + `, + [auth.tenantId, regionId || null, includeInactive, limit], + ); + return { items }; +} + +export async function upsertBannerRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.banners ( + id, tenant_id, region_id, legacy_id, title, subtitle, content, + button_text, button_link, bg_color, border_color, sort_order, is_active + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13 + ) + on conflict (id) + do update set region_id = excluded.region_id, + legacy_id = coalesce(excluded.legacy_id, public.banners.legacy_id), + title = excluded.title, + subtitle = excluded.subtitle, + content = excluded.content, + button_text = excluded.button_text, + button_link = excluded.button_link, + bg_color = excluded.bg_color, + border_color = excluded.border_color, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + where public.banners.tenant_id = excluded.tenant_id + returning 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", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.regionId), + nullableString(body.legacyId), + nullableString(body.title), + nullableString(body.subtitle), + nullableString(body.content), + nullableString(body.buttonText), + nullableString(body.buttonLink), + nullableString(body.bgColor), + nullableString(body.borderColor), + intValue(body.order, 0), + boolValue(body.isActive, true), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Banner not found for this tenant', 'BANNER_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.banner.upserted', 'banners', result.rows[0].id); + return result.rows[0]; + }); + return { item }; +} + +export async function faqsAdminRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:read'); + const regionId = stringParam(ctx, 'regionId'); + const limit = intParam(ctx, 'limit', 100, 500); + const includeInactive = ctx.url.searchParams.get('includeInactive') === 'true'; + 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 tenant_id = $1 + and ($2::uuid is null or region_id = $2::uuid) + and ($3::boolean or is_active = true) + order by sort_order asc, created_at desc + limit $4 + `, + [auth.tenantId, regionId || null, includeInactive, limit], + ); + return { items }; +} + +export async function upsertFaqRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.faqs ( + id, tenant_id, region_id, legacy_id, question, answer, sort_order, is_active + ) + values (coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4, $5, $6, $7, $8) + on conflict (id) + do update set region_id = excluded.region_id, + legacy_id = coalesce(excluded.legacy_id, public.faqs.legacy_id), + question = excluded.question, + answer = excluded.answer, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + where public.faqs.tenant_id = excluded.tenant_id + returning id, legacy_id as "legacyId", region_id as "regionId", + question, answer, sort_order as "order", is_active as "isActive", + updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.regionId), + nullableString(body.legacyId), + requiredString(body, 'question'), + nullableString(body.answer), + intValue(body.order, 0), + boolValue(body.isActive, true), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'FAQ not found for this tenant', 'FAQ_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.faq.upserted', 'faqs', result.rows[0].id); + return result.rows[0]; + }); + return { item }; +} + +export async function announcementsAdminRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const includeInactive = ctx.url.searchParams.get('includeInactive') === 'true'; + 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 ($2::boolean or is_active = true) + order by sort_order asc, created_at desc + limit $3 + `, + [auth.tenantId, includeInactive, limit], + ); + return { items }; +} + +export async function upsertAnnouncementRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.announcements ( + id, tenant_id, legacy_id, content, link, bg_color, sort_order, is_active + ) + values (coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7, $8) + on conflict (id) + do update set legacy_id = coalesce(excluded.legacy_id, public.announcements.legacy_id), + content = excluded.content, + link = excluded.link, + bg_color = excluded.bg_color, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + where public.announcements.tenant_id = excluded.tenant_id + returning id, legacy_id as "legacyId", content, link, bg_color as "bgColor", + sort_order as "order", is_active as "isActive", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.legacyId), + requiredString(body, 'content'), + nullableString(body.link), + nullableString(body.bgColor), + intValue(body.order, 0), + boolValue(body.isActive, true), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Announcement not found for this tenant', 'ANNOUNCEMENT_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.announcement.upserted', 'announcements', result.rows[0].id); + return result.rows[0]; + }); + return { item }; +} + +export async function codeBatchesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'codes:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const items = await query( + ` + select id, legacy_id as "legacyId", name, sale_type as "saleType", channel, + campaign_name as "campaignName", default_unit_price_cents as "defaultUnitPriceCents", + cost_price_cents as "costPriceCents", total_count as "totalCount", days, + region_id as "regionId", issued_at as "issuedAt", created_by as "createdBy", + remark, commission_rate as "commissionRate", created_at as "createdAt", updated_at as "updatedAt" + from public.code_batches + where tenant_id = $1 + order by created_at desc + limit $2 + `, + [auth.tenantId, limit], + ); + return { items }; +} + +export async function upsertCodeBatchRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'codes:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.code_batches ( + id, tenant_id, legacy_id, name, sale_type, channel, campaign_name, + default_unit_price_cents, cost_price_cents, total_count, days, + region_id, issued_at, created_by, remark, commission_rate + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12::uuid, $13::timestamptz, $14, $15, $16 + ) + on conflict (id) + do update set legacy_id = coalesce(excluded.legacy_id, public.code_batches.legacy_id), + name = excluded.name, + sale_type = excluded.sale_type, + channel = excluded.channel, + campaign_name = excluded.campaign_name, + default_unit_price_cents = excluded.default_unit_price_cents, + cost_price_cents = excluded.cost_price_cents, + total_count = excluded.total_count, + days = excluded.days, + region_id = excluded.region_id, + issued_at = excluded.issued_at, + remark = excluded.remark, + commission_rate = excluded.commission_rate, + updated_at = now() + where public.code_batches.tenant_id = excluded.tenant_id + returning id, legacy_id as "legacyId", name, sale_type as "saleType", channel, + campaign_name as "campaignName", default_unit_price_cents as "defaultUnitPriceCents", + cost_price_cents as "costPriceCents", total_count as "totalCount", days, + region_id as "regionId", issued_at as "issuedAt", created_by as "createdBy", + remark, commission_rate as "commissionRate", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.legacyId), + requiredString(body, 'name'), + nullableString(body.saleType), + nullableString(body.channel), + nullableString(body.campaignName), + intValue(body.defaultUnitPriceCents, 0), + intValue(body.costPriceCents, 0), + intValue(body.totalCount, 0), + body.days === undefined ? null : intValue(body.days, 0), + nullableString(body.regionId), + nullableString(body.issuedAt), + auth.userId, + nullableString(body.remark), + numberValue(body.commissionRate, null), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Code batch not found for this tenant', 'CODE_BATCH_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.code_batch.upserted', 'code_batches', result.rows[0].id); + return result.rows[0]; + }); + return { item }; +} + +export async function activationCodesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'codes:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const batchId = stringParam(ctx, 'batchId'); + const code = stringParam(ctx, 'code'); + const isUsed = ctx.url.searchParams.get('isUsed'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (batchId) { + params.push(batchId); + filters.push(`batch_id = $${params.length}::uuid`); + } + if (code) { + params.push(`%${code}%`); + filters.push(`code::text ilike $${params.length}`); + } + if (isUsed === 'true' || isUsed === 'false') { + params.push(isUsed === 'true'); + filters.push(`is_used = $${params.length}`); + } + params.push(limit); + + const items = await query( + ` + select id, legacy_id as "legacyId", code, days, is_used as "isUsed", + used_by as "usedBy", used_at as "usedAt", agent_user_id as "agentUserId", + batch_id as "batchId", sale_type as "saleType", unit_price_cents as "unitPriceCents", + sold_to as "soldTo", used_region_id as "usedRegionId", coupon_code as "couponCode", + coupon_redemption_id as "couponRedemptionId", remark, created_at as "createdAt", + updated_at as "updatedAt" + from public.activation_codes + where ${filters.join(' and ')} + order by created_at desc + limit $${params.length} + `, + params, + ); + return { items }; +} + +export async function upsertActivationCodeRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'codes:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.activation_codes ( + id, tenant_id, legacy_id, code, days, batch_id, agent_user_id, + sale_type, unit_price_cents, sold_to, used_region_id, coupon_code, remark + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6::uuid, $7::uuid, + $8, $9, $10, $11::uuid, $12, $13 + ) + on conflict (tenant_id, code) + do update set days = excluded.days, + batch_id = excluded.batch_id, + agent_user_id = excluded.agent_user_id, + sale_type = excluded.sale_type, + unit_price_cents = excluded.unit_price_cents, + sold_to = excluded.sold_to, + used_region_id = excluded.used_region_id, + coupon_code = excluded.coupon_code, + remark = excluded.remark, + updated_at = now() + returning id, legacy_id as "legacyId", code, days, is_used as "isUsed", + used_by as "usedBy", used_at as "usedAt", agent_user_id as "agentUserId", + batch_id as "batchId", sale_type as "saleType", unit_price_cents as "unitPriceCents", + sold_to as "soldTo", used_region_id as "usedRegionId", coupon_code as "couponCode", + remark, updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.legacyId), + codeValue(body), + intValue(body.days, 0), + nullableString(body.batchId), + nullableString(body.agentUserId), + nullableString(body.saleType), + body.unitPriceCents === undefined ? null : intValue(body.unitPriceCents, 0), + nullableString(body.soldTo), + nullableString(body.usedRegionId), + nullableString(body.couponCode), + nullableString(body.remark), + ], + ); + await recordAudit(client, auth, 'tenant.activation_code.upserted', 'activation_codes', result.rows[0].id, { + code: result.rows[0].code, + }); + return result.rows[0]; + }); + return { item }; +} + +export async function generateActivationCodesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'codes:write'); + const body = await readJsonBody(ctx); + const batchId = requiredString(body, 'batchId'); + const count = Math.min(Math.max(intValue(body.count, 1), 1), 1000); + const prefix = (nullableString(body.prefix) || '').replace(/\s+/g, '').toUpperCase(); + + const result = await transaction(async client => { + const batch = await client.query<{ id: string; days: number | null; sale_type: string | null; default_unit_price_cents: number }>( + ` + select id, days, sale_type, default_unit_price_cents + from public.code_batches + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, batchId], + ); + if (!batch.rows[0]) throw new HttpError(404, 'Code batch not found', 'CODE_BATCH_NOT_FOUND'); + + const items: unknown[] = []; + let attempts = 0; + while (items.length < count && attempts < count * 5) { + attempts += 1; + const code = randomCode(prefix); + const insert = await client.query( + ` + insert into public.activation_codes ( + tenant_id, code, days, batch_id, sale_type, unit_price_cents, sold_to, remark + ) + values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (tenant_id, code) do nothing + returning id, code, days, batch_id as "batchId", sale_type as "saleType", + unit_price_cents as "unitPriceCents", sold_to as "soldTo", + remark, created_at as "createdAt" + `, + [ + auth.tenantId, + code, + body.days === undefined ? batch.rows[0].days || 0 : intValue(body.days, 0), + batchId, + nullableString(body.saleType) || batch.rows[0].sale_type, + body.unitPriceCents === undefined ? batch.rows[0].default_unit_price_cents : intValue(body.unitPriceCents, 0), + nullableString(body.soldTo), + nullableString(body.remark), + ], + ); + if (insert.rows[0]) items.push(insert.rows[0]); + } + + await client.query( + 'update public.code_batches set total_count = total_count + $3, updated_at = now() where tenant_id = $1 and id = $2', + [auth.tenantId, batchId, items.length], + ); + await recordAudit(client, auth, 'tenant.activation_codes.generated', 'activation_codes', batchId, { + count: items.length, + prefix, + }); + + return { count: items.length, items }; + }); + + return result; +} + +export async function couponsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'coupons:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const items = await query( + ` + select id, legacy_id as "legacyId", code, plan_id as "planId", + discount_type as "discountType", discount_value as "discountValue", + valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses", + used_count as "usedCount", source, remark, created_at as "createdAt", updated_at as "updatedAt" + from public.coupons + where tenant_id = $1 + order by created_at desc + limit $2 + `, + [auth.tenantId, limit], + ); + return { items }; +} + +export async function upsertCouponRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'coupons:write'); + const body = await readJsonBody(ctx); + const discountType = body.discountType ? optionalChoice(body.discountType, DISCOUNT_TYPES, 'fixed') : null; + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.coupons ( + id, tenant_id, legacy_id, code, plan_id, discount_type, discount_value, + valid_from, valid_to, max_uses, source, remark + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5::uuid, $6, $7, + $8::timestamptz, $9::timestamptz, $10, $11, $12 + ) + on conflict (tenant_id, code) + do update set plan_id = excluded.plan_id, + discount_type = excluded.discount_type, + discount_value = excluded.discount_value, + valid_from = excluded.valid_from, + valid_to = excluded.valid_to, + max_uses = excluded.max_uses, + source = excluded.source, + remark = excluded.remark, + updated_at = now() + returning id, legacy_id as "legacyId", code, plan_id as "planId", + discount_type as "discountType", discount_value as "discountValue", + valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses", + used_count as "usedCount", source, remark, updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + nullableString(body.legacyId), + codeValue(body), + nullableString(body.planId), + discountType, + numberValue(body.discountValue, null), + nullableString(body.validFrom), + nullableString(body.validTo), + body.maxUses === undefined ? null : intValue(body.maxUses, 0), + nullableString(body.source), + nullableString(body.remark), + ], + ); + await recordAudit(client, auth, 'tenant.coupon.upserted', 'coupons', result.rows[0].id, { + code: result.rows[0].code, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function tenantMembersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'members:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const role = stringParam(ctx, 'role'); + const status = stringParam(ctx, 'status'); + const keyword = stringParam(ctx, 'keyword'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tm.tenant_id = $1']; + + if (role) { + if (!TENANT_MEMBER_ROLES.includes(role)) { + throw new HttpError(400, `Invalid member role: ${role}`, 'INVALID_MEMBER_ROLE'); + } + params.push(role); + filters.push(`tm.role = $${params.length}`); + } + if (status) { + if (!TENANT_MEMBER_STATUSES.includes(status)) { + throw new HttpError(400, `Invalid member status: ${status}`, 'INVALID_MEMBER_STATUS'); + } + params.push(status); + filters.push(`tm.status = $${params.length}`); + } + if (keyword) { + params.push(`%${keyword}%`); + filters.push(`( + u.username ilike $${params.length} + or u.name ilike $${params.length} + or u.phone ilike $${params.length} + or u.email::text ilike $${params.length} + )`); + } + params.push(limit); + + const items = await query( + ` + select tm.id, tm.user_id as "userId", tm.role, tm.status, tm.permissions, + tm.legacy_role as "legacyRole", tm.created_at as "createdAt", tm.updated_at as "updatedAt", + u.username, u.email::text as email, u.phone, u.name, u.avatar_url as "avatarUrl", + u.primary_role as "primaryRole", u.last_seen_at as "lastSeenAt" + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + where ${filters.join(' and ')} + order by case tm.role + when 'tenant_owner' then 1 + when 'tenant_admin' then 2 + when 'tenant_operator' then 3 + when 'teacher' then 4 + when 'sales' then 5 + when 'agent' then 6 + else 9 + end, tm.created_at asc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function upsertTenantMemberRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'members:write'); + const body = await readJsonBody(ctx); + const membershipId = nullableString(body.membershipId) || nullableString(body.id); + const role = requiredMemberRole(body.role); + const status = optionalChoice(body.status, TENANT_MEMBER_STATUSES, 'active'); + const permissions = permissionValue(body.permissions); + ensureCanGrantRole(auth, role, permissions); + + const item = await transaction(async client => { + const userId = await resolveOrCreateMemberUser(client, body); + if (userId === auth.userId && status === 'disabled') { + throw new HttpError(400, 'Cannot disable your own tenant membership', 'CANNOT_DISABLE_SELF'); + } + await ensureOwnerRemains(client, auth.tenantId, membershipId, role, status); + + let result; + if (membershipId) { + result = await client.query( + ` + update public.tenant_memberships + set user_id = $3, + role = $4, + status = $5, + permissions = $6::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, user_id as "userId", role, status, permissions, + legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, membershipId, userId, role, status, JSON.stringify(permissions)], + ); + } else { + result = await client.query( + ` + insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) + values ($1, $2, $3, $4, $5::jsonb) + on conflict (tenant_id, user_id, role) + do update set status = excluded.status, + permissions = excluded.permissions, + updated_at = now() + returning id, user_id as "userId", role, status, permissions, + legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, userId, role, status, JSON.stringify(permissions)], + ); + } + + if (!result.rows[0]) throw new HttpError(404, 'Tenant member not found', 'TENANT_MEMBER_NOT_FOUND'); + + await recordAudit(client, auth, 'tenant.member.upserted', 'tenant_memberships', result.rows[0].id, { + userId, + role, + status, + permissionKeys: Object.keys(permissions), + }); + + return result.rows[0]; + }); + + return { item }; +} + +export async function disableTenantMemberRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'members:write'); + const body = await readJsonBody(ctx); + const membershipId = requiredString(body, 'membershipId'); + + const item = await transaction(async client => { + const existing = await client.query<{ userId: string; role: string; status: string }>( + ` + select user_id as "userId", role, status + from public.tenant_memberships + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, membershipId], + ); + if (!existing.rows[0]) throw new HttpError(404, 'Tenant member not found', 'TENANT_MEMBER_NOT_FOUND'); + if (existing.rows[0].userId === auth.userId) { + throw new HttpError(400, 'Cannot disable your own tenant membership', 'CANNOT_DISABLE_SELF'); + } + ensureCanGrantRole(auth, existing.rows[0].role, {}); + await ensureOwnerRemains(client, auth.tenantId, membershipId, existing.rows[0].role, 'disabled'); + + const result = await client.query( + ` + update public.tenant_memberships + set status = 'disabled', + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, user_id as "userId", role, status, permissions, + legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, membershipId], + ); + + await recordAudit(client, auth, 'tenant.member.disabled', 'tenant_memberships', membershipId, { + userId: result.rows[0].userId, + role: result.rows[0].role, + }); + + return result.rows[0]; + }); + + return { item }; +} + +export async function auditLogsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'audit:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const action = stringParam(ctx, 'action'); + const targetType = stringParam(ctx, 'targetType'); + const actorUserId = stringParam(ctx, 'actorUserId'); + const params: unknown[] = [auth.tenantId]; + const filters = ['al.tenant_id = $1']; + + if (action) { + params.push(`${action}%`); + filters.push(`al.action ilike $${params.length}`); + } + if (targetType) { + params.push(targetType); + filters.push(`al.target_type = $${params.length}`); + } + if (actorUserId) { + params.push(actorUserId); + filters.push(`al.actor_user_id = $${params.length}::uuid`); + } + params.push(limit); + + const items = await query( + ` + select al.id, al.actor_user_id as "actorUserId", al.action, + al.target_type as "targetType", al.target_id as "targetId", + al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent", + al.created_at as "createdAt", + u.username as "actorUsername", u.name as "actorName", u.phone as "actorPhone" + from public.audit_logs al + left join public.platform_users u on u.id = al.actor_user_id + where ${filters.join(' and ')} + order by al.created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} diff --git a/apps/api/src/features/tenant-content/assets.ts b/apps/api/src/features/tenant-content/assets.ts new file mode 100644 index 00000000..629df40c --- /dev/null +++ b/apps/api/src/features/tenant-content/assets.ts @@ -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) { + 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( + ` + 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), + }; +} diff --git a/apps/api/src/features/tenant-content/auth.ts b/apps/api/src/features/tenant-content/auth.ts new file mode 100644 index 00000000..b6fd37a8 --- /dev/null +++ b/apps/api/src/features/tenant-content/auth.ts @@ -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; +} + +export async function requireTenantContentEditor(ctx: RequestContext): Promise { + const tenantId = tenantIdFrom(ctx); + const userId = userIdFrom(ctx); + + const membership = await queryOne<{ role: string; permissions: Record }>( + ` + 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 || {} }; +} diff --git a/apps/api/src/features/tenant-content/imports.ts b/apps/api/src/features/tenant-content/imports.ts new file mode 100644 index 00000000..5fb061e3 --- /dev/null +++ b/apps/api/src/features/tenant-content/imports.ts @@ -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; + +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 { + 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 }; +} diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts new file mode 100644 index 00000000..dbc8ee47 --- /dev/null +++ b/apps/api/src/features/tenant-content/index.ts @@ -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], +]; diff --git a/apps/api/src/features/tenant-content/routes.ts b/apps/api/src/features/tenant-content/routes.ts new file mode 100644 index 00000000..6c6dd584 --- /dev/null +++ b/apps/api/src/features/tenant-content/routes.ts @@ -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 }; +} diff --git a/apps/api/src/features/tenant-content/utils.ts b/apps/api/src/features/tenant-content/utils.ts new file mode 100644 index 00000000..230cea0d --- /dev/null +++ b/apps/api/src/features/tenant-content/utils.ts @@ -0,0 +1,32 @@ +import { HttpError } from '../../core/http.js'; + +export type JsonObject = Record; + +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; +} diff --git a/apps/api/src/features/tenant/index.ts b/apps/api/src/features/tenant/index.ts new file mode 100644 index 00000000..c289701a --- /dev/null +++ b/apps/api/src/features/tenant/index.ts @@ -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], +]; diff --git a/apps/api/src/features/tenant/routes.ts b/apps/api/src/features/tenant/routes.ts new file mode 100644 index 00000000..1d992e29 --- /dev/null +++ b/apps/api/src/features/tenant/routes.ts @@ -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; + feature_flags: Record; + admin_feature_flags: Record; + public_config: Record; +} + +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( + ` + 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( + ` + 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 || {}, + }; +} diff --git a/apps/api/src/features/video/index.ts b/apps/api/src/features/video/index.ts new file mode 100644 index 00000000..59b38aa1 --- /dev/null +++ b/apps/api/src/features/video/index.ts @@ -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], +]; diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts new file mode 100644 index 00000000..e84b3db9 --- /dev/null +++ b/apps/api/src/features/video/routes.ts @@ -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( + ` + ${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( + ` + ${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 = {}; + 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 }; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 00000000..b1cec644 --- /dev/null +++ b/apps/api/src/server.ts @@ -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}`); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000..d8eb659e --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/docker-compose.api.yml b/docker-compose.api.yml new file mode 100644 index 00000000..316be187 --- /dev/null +++ b/docker-compose.api.yml @@ -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 diff --git a/docs/pb_schema.json b/docs/pb_schema.json new file mode 100644 index 00000000..43d8fc32 --- /dev/null +++ b/docs/pb_schema.json @@ -0,0 +1,10917 @@ +[ + { + "id": "_pb_users_auth_", + "listRule": "@request.auth.id != \"\" && (@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\" || @request.auth.role = \"sales\" || @request.auth.role = \"agent\" || id = @request.auth.id)", + "viewRule": "@request.auth.id != \"\" ", + "createRule": "", + "updateRule": "(@request.auth.id = id && @request.body.questionsAnswered:isset = false && @request.body.role:isset = false && @request.body.isSvip:isset = false && @request.body.svipExpiry:isset = false && @request.body.svipRegionId:isset = false && @request.body.inviteCode:isset = false && @request.body.invitedBy:isset = false && @request.body.directInviter:isset = false && @request.body.phoneBound:isset = false) || @request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\"\n", + "name": "users", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 255, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "file376926767", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "help": "", + "hidden": false, + "id": "select1466534506", + "maxSelect": 1, + "name": "role", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "student", + "admin", + "agent", + "operator", + "sales" + ] + }, + { + "help": "", + "hidden": false, + "id": "bool1303736402", + "name": "isSvip", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number848901969", + "max": null, + "min": null, + "name": "score", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2479696133", + "max": null, + "min": null, + "name": "questionsAnswered", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json1464297386", + "maxSize": 0, + "name": "stats", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "date3495900423", + "max": "", + "min": "", + "name": "svipExpiry", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "number1542800728", + "max": null, + "min": null, + "name": "field", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json570552902", + "maxSize": 0, + "name": "progress", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text787483488", + "max": 0, + "min": 0, + "name": "selectedSchoolId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2294201960", + "max": 0, + "min": 0, + "name": "lastCheckInDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1146066909", + "max": 11, + "min": 0, + "name": "phone", + "pattern": "^1[3-9]\\d{9}$", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1842249112", + "max": 0, + "min": 0, + "name": "qqOpenId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2371843074", + "max": 0, + "min": 0, + "name": "wechatUnionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number965670141", + "max": null, + "min": null, + "name": "masteredWordsCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1442594332", + "help": "", + "hidden": false, + "id": "relation4065802358", + "maxSelect": 1, + "minSelect": 0, + "name": "selectedMajorId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text432700299", + "max": 0, + "min": 0, + "name": "inviteCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3607751814", + "maxSelect": 1, + "minSelect": 0, + "name": "invitedBy", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "bool3673059515", + "name": "phoneBound", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "json2954543068", + "maxSize": 0, + "name": "recentActivities", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "bool3742215461", + "name": "hasPassword", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3874557153", + "max": 0, + "min": 0, + "name": "svipRegionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4051743964", + "max": 0, + "min": 0, + "name": "wechatOpenId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": true, + "id": "text4289328774", + "max": 0, + "min": 0, + "name": "wechatSessionKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json1530284479", + "maxSize": 0, + "name": "svipRegions", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1536135285", + "max": 0, + "min": 0, + "name": "moduleSelections", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2384009926", + "maxSelect": 1, + "minSelect": 0, + "name": "directInviter", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "date1925370682", + "max": "", + "min": "", + "name": "lastSeenAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "number2083397328", + "max": 1, + "min": 0, + "name": "commissionRate", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2753293999", + "maxSelect": 1, + "minSelect": 0, + "name": "leaderId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "number674895824", + "max": null, + "min": null, + "name": "bonusQuota", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text85363776", + "max": 0, + "min": 0, + "name": "lastShareUnlockDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number357803286", + "max": null, + "min": null, + "name": "claimedReferralTier", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey__pb_users_auth_` ON `users` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email__pb_users_auth_` ON `users` (`email`) WHERE `email` != ''", + "CREATE INDEX idx_users_lastSeenAt ON users (lastSeenAt)" + ], + "system": false, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "新的登录设备(地址)", + "body": "

你好同学,

\n

我们注意到你的 {APP_NAME} 账号在新的地方登录了:

\n

如果不是你主动登录了 {APP_NAME} 账号,请记得修改密码,保证账号安全。

\n

如果是你登录的,那请忽略本邮件。

\n

\n 一战成本,
\n {APP_NAME} 团队 (本邮件为系统自动发送,请勿回复)\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "name", + "username": "", + "avatarURL": "avatar" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 604800 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 259200 + }, + "fileToken": { + "duration": 180 + }, + "verificationTemplate": { + "subject": "确认 {APP_NAME} 邮件", + "body": "
\n
\n

{APP_NAME}

\n
\n

密码重置请求

\n

您好,

\n

我们收到了您的密码重置请求。请点击下方链接设置新密码:

\n \n

\n \n 👉 点击此处重置密码\n

\n \n

链接有效期为 30 分钟。如果这不是您的操作,请忽略。

\n
" + }, + "resetPasswordTemplate": { + "subject": "重新设置 {APP_NAME} 密码", + "body": "
\n
\n

{APP_NAME}

\n
\n

密码重置请求

\n

您好,

\n

我们收到了您的密码重置请求。请点击下方链接设置新密码:

\n

\n 👉 点击此处重置密码\n

\n

链接有效期为 30 分钟。如果这不是您的操作,请忽略。

\n
" + }, + "confirmEmailChangeTemplate": { + "subject": "确认 {APP_NAME} 新的邮箱绑定", + "body": "
\n
\n

{APP_NAME}

\n
\n

新的邮箱绑定请求

\n

您好,

\n

我们收到了您的新的邮箱绑定请求。请点击下方链接设置:

\n \n

\n 👉 点击此处更换新邮箱\n

\n \n

链接有效期为 30 分钟。如果这不是您的操作,请忽略。

\n
" + } + }, + { + "id": "pbc_3142635823", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_superusers", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''" + ], + "system": true, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "", + "username": "", + "avatarURL": "" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 86400 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 259200 + }, + "fileToken": { + "duration": 180 + }, + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + { + "id": "pbc_3866499052", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "announcements", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4274335913", + "max": 0, + "min": 0, + "name": "content", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text917281265", + "max": 0, + "min": 0, + "name": "link", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4110495136", + "max": 0, + "min": 0, + "name": "bgColor", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1650598046", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "app_assets", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2324736937", + "max": 0, + "min": 0, + "name": "key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text196455508", + "max": 0, + "min": 0, + "name": "desc", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_681515208", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "audit_logs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1204587666", + "max": 0, + "min": 0, + "name": "action", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text962958965", + "max": 0, + "min": 0, + "name": "targetId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2929181464", + "max": 0, + "min": 0, + "name": "targetType", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "detail", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2783163181", + "max": 0, + "min": 0, + "name": "ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3236859723", + "max": 0, + "min": 0, + "name": "userAgent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1326724116", + "max": 0, + "min": 0, + "name": "metadata", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1340419796", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "badges", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select105650625", + "maxSelect": 0, + "name": "category", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "questions ", + "accuracy", + "event" + ] + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url1461831018", + "name": "icon_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "help": "", + "hidden": false, + "id": "number2599078931", + "max": null, + "min": null, + "name": "level", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "select1475658487", + "maxSelect": 0, + "name": "unlock_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "auto", + "manual" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text388931923", + "max": 0, + "min": 0, + "name": "condition_field", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select4041242008", + "maxSelect": 0, + "name": "condition_operator", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "gte(>=)", + "eq(==) ", + "gt(>)" + ] + }, + { + "help": "", + "hidden": false, + "id": "number1370274879", + "max": null, + "min": null, + "name": "condition_value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json31779182", + "maxSize": 0, + "name": "condition_extra", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number1169138922", + "max": null, + "min": null, + "name": "sort_order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3025951362", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "banners", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1367709617", + "max": 0, + "min": 0, + "name": "subtitle", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4274335913", + "max": 0, + "min": 0, + "name": "content", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3654087349", + "max": 0, + "min": 0, + "name": "buttonText", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3572223107", + "max": 0, + "min": 0, + "name": "buttonLink", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4110495136", + "max": 0, + "min": 0, + "name": "bgColor", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text938924930", + "max": 0, + "min": 0, + "name": "borderColor", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3292755704", + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "categories", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3949707534", + "help": "", + "hidden": false, + "id": "relation1542800728", + "maxSelect": 1, + "minSelect": 0, + "name": "subjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select1145392311", + "maxSelect": 1, + "name": "categoryType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "chapter", + "paper" + ] + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2911045785", + "max": null, + "min": null, + "name": "svipQuestionLimit", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2672911817", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "code_batches", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 200, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select1232084572", + "maxSelect": 1, + "name": "saleType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "full_price", + "campaign", + "gift", + "partner", + "agent", + "unclassified" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2734263879", + "max": 200, + "min": 0, + "name": "channel", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2402299011", + "max": 200, + "min": 0, + "name": "campaignName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number1889495825", + "max": null, + "min": 0, + "name": "defaultUnitPrice", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number171205753", + "max": null, + "min": 0, + "name": "costPrice", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1730513024", + "max": null, + "min": 0, + "name": "totalCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3957652582", + "max": null, + "min": null, + "name": "days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 50, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date917377331", + "max": "", + "min": "", + "name": "issuedAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3545646658", + "max": 50, + "min": 0, + "name": "createdBy", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3788167225", + "max": 100, + "min": 0, + "name": "remark", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "number2083397328", + "max": 1, + "min": 0, + "name": "commissionRate", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_787185574", + "listRule": "@request.auth.id != \"\" && (@request.auth.role = \"admin\" || (@request.auth.role = \"agent\" && agentId = @request.auth.id))", + "viewRule": "@request.auth.id != \"\" && (@request.auth.role = \"admin\" || (@request.auth.role = \"agent\" && agentId = @request.auth.id))", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "codes", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1997877400", + "max": 0, + "min": 0, + "name": "code", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3957652582", + "max": null, + "min": null, + "name": "days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool3631904276", + "name": "isUsed", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation4243531548", + "maxSelect": 1, + "minSelect": 0, + "name": "usedBy", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "date2842888290", + "max": "", + "min": "", + "name": "usedAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3788167225", + "max": 0, + "min": 0, + "name": "remark", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text401296961", + "max": 0, + "min": 0, + "name": "agentId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1250724666", + "max": 50, + "min": 0, + "name": "batchId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select1232084572", + "maxSelect": 1, + "name": "saleType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "full_price", + "campaign", + "gift", + "partner", + "agent", + "unclassified" + ] + }, + { + "help": "", + "hidden": false, + "id": "number3672808604", + "max": null, + "min": 0, + "name": "unitPrice", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text451080590", + "max": 200, + "min": 0, + "name": "soldTo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2475423408", + "max": 50, + "min": 0, + "name": "usedRegionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2073813686", + "max": 50, + "min": 0, + "name": "couponCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2219448038", + "max": 50, + "min": 0, + "name": "couponRedemptionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_596279677", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "commission_settings", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number483588816", + "max": 1, + "min": 0, + "name": "defaultRate", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3788167225", + "max": 500, + "min": 0, + "name": "remark", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2752421888", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "coupon_redemptions", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2934794482", + "max": 50, + "min": 0, + "name": "couponId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2073813686", + "max": 50, + "min": 0, + "name": "couponCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1689669068", + "max": 50, + "min": 0, + "name": "userId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1284837743", + "max": 50, + "min": 0, + "name": "planId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "claimed", + "used", + "expired", + "revoked" + ] + }, + { + "help": "", + "hidden": false, + "id": "date2585205160", + "max": "", + "min": "", + "name": "claimedAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "date2842888290", + "max": "", + "min": "", + "name": "usedAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4196627511", + "max": 50, + "min": 0, + "name": "orderId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2117076406", + "max": null, + "min": 0, + "name": "discountApplied", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 50, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1602912115", + "max": 100, + "min": 0, + "name": "source", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3788167225", + "max": 500, + "min": 0, + "name": "remark", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1681354482", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "coupons", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1997877400", + "max": 50, + "min": 0, + "name": "code", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1284837743", + "max": 50, + "min": 0, + "name": "planId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2266495210", + "maxSelect": 1, + "name": "discountType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "percent", + "fixed" + ] + }, + { + "help": "", + "hidden": false, + "id": "number521208465", + "max": null, + "min": 0, + "name": "discountValue", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date1321367048", + "max": "", + "min": "", + "name": "validFrom", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "date2274640688", + "max": "", + "min": "", + "name": "validTo", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "number115675991", + "max": null, + "min": 0, + "name": "maxUses", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3056842290", + "max": null, + "min": 0, + "name": "usedCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "select1602912115", + "maxSelect": 1, + "name": "source", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "live", + "offline", + "gift", + "campaign" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3788167225", + "max": 100, + "min": 0, + "name": "remark", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_42249773", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "crm_config", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool1358543748", + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1554180325", + "max": 0, + "min": 0, + "name": "secret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4131740491", + "max": 0, + "min": 0, + "name": "formName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2366416882", + "max": 0, + "min": 0, + "name": "examType", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4035868241", + "max": null, + "min": null, + "name": "timeoutSec", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2274710345", + "max": null, + "min": null, + "name": "delaySec", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4262410958", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "crm_webhook_log", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text576187720", + "max": 50, + "min": 0, + "name": "recordId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3775496189", + "max": null, + "min": null, + "name": "httpCode", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "select817655234", + "maxSelect": 1, + "name": "outcome", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "success", + "retryable", + "discarded", + "network_error" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text370503141", + "max": 2000, + "min": 0, + "name": "errorMessage", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text939930664", + "max": 100, + "min": 0, + "name": "leadId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4225498170", + "max": 20000, + "min": 0, + "name": "requestBody", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2480774995", + "max": 2000, + "min": 0, + "name": "responseSummary", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2534152526", + "max": "", + "min": "", + "name": "signedAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "number418120294", + "max": null, + "min": 0, + "name": "attempt", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + } + ], + "indexes": [ + "CREATE INDEX idx_crm_webhook_log_recordId ON crm_webhook_log (recordId)", + "CREATE INDEX idx_crm_webhook_log_signedAt ON crm_webhook_log (signedAt)" + ], + "system": false + }, + { + "id": "pbc_426567488", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "crm_webhook_queue", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text576187720", + "max": 50, + "min": 0, + "name": "recordId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "pending", + "sent", + "failed", + "discarded" + ] + }, + { + "help": "", + "hidden": false, + "id": "date3437196906", + "max": "", + "min": "", + "name": "scheduledAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "number3217549156", + "max": null, + "min": 0, + "name": "attempts", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date3058820946", + "max": "", + "min": "", + "name": "nextAttemptAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1460807474", + "max": 2000, + "min": 0, + "name": "lastError", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number180076702", + "max": null, + "min": null, + "name": "lastHttpCode", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text939930664", + "max": 100, + "min": 0, + "name": "leadId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date1538108716", + "max": "", + "min": "", + "name": "sentAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX idx_crm_webhook_queue_recordId ON crm_webhook_queue (recordId)", + "CREATE INDEX idx_crm_webhook_queue_status_scheduled ON crm_webhook_queue (status, scheduledAt)" + ], + "system": false + }, + { + "id": "pbc_1779123624", + "listRule": "@request.auth.role = 'admin'\t", + "viewRule": "@request.auth.role = 'admin'\t", + "createRule": "", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'\t", + "name": "customer_messages", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text31680599", + "max": 0, + "min": 0, + "name": "openid", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3691053410", + "max": 0, + "min": 0, + "name": "msgType", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4274335913", + "max": 0, + "min": 0, + "name": "content", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4176922427", + "max": 0, + "min": 0, + "name": "msgId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2638645845", + "max": "", + "min": "", + "name": "createTime", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "bool670768011", + "name": "processed", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3241298086", + "max": 0, + "min": 0, + "name": "replyContent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3239221046", + "listRule": "@request.auth.role = 'admin'", + "viewRule": "@request.auth.role = 'admin'", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "dashboard_cache", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2324736937", + "max": 0, + "min": 0, + "name": "key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2732461179", + "max": 0, + "min": 0, + "name": "timeRange", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json2918445923", + "maxSize": 0, + "name": "data", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1469549024", + "name": "computedAt", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2653327544", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "dashboard_daily_stats", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3150471306", + "max": 10, + "min": 0, + "name": "statDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 50, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2089438763", + "max": null, + "min": 0, + "name": "newUsers", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1636048698", + "max": null, + "min": 0, + "name": "newQuestions", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2418890649", + "max": null, + "min": 0, + "name": "newOrders", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number654674608", + "max": null, + "min": 0, + "name": "newRevenue", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1711761446", + "max": null, + "min": 0, + "name": "activeUsers", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date4021405430", + "max": "", + "min": "", + "name": "rebuiltAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + } + ], + "indexes": [ + "CREATE INDEX idx_dashboard_daily_uniq ON dashboard_daily_stats (statDate, regionId)", + "CREATE INDEX idx_dashboard_daily_statDate ON dashboard_daily_stats (statDate)", + "CREATE INDEX idx_dashboard_daily_regionId ON dashboard_daily_stats (regionId)" + ], + "system": false + }, + { + "id": "pbc_3903889340", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "exam_dates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3591849023", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1609666269", + "max": 0, + "min": 0, + "name": "examName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2873935777", + "max": "", + "min": "", + "name": "examDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "select2366416882", + "maxSelect": 1, + "name": "examType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "unified", + "school" + ] + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2923026773", + "listRule": "isActive = true", + "viewRule": "isActive = true", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "faqs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3069659470", + "max": 0, + "min": 0, + "name": "question", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3671935525", + "max": 0, + "min": 0, + "name": "answer", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3177816868", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "handbook_chapters", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1563785033", + "help": "", + "hidden": false, + "id": "relation1040987371", + "maxSelect": 1, + "minSelect": 0, + "name": "subjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_256003662", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "handbook_entries", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3458754147", + "max": 0, + "min": 0, + "name": "summary", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json1874629670", + "maxSize": 0, + "name": "tags", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "convertURLs": false, + "help": "", + "hidden": false, + "id": "editor4274335913", + "maxSize": 0, + "name": "content", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3177816868", + "help": "", + "hidden": false, + "id": "relation2595353692", + "maxSelect": 1, + "minSelect": 0, + "name": "chapterId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1563785033", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "handbook_subjects", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1704208859", + "max": 0, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1716930793", + "max": 0, + "min": 0, + "name": "color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "cultural", + "professional" + ] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3591849023", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1442594332", + "help": "", + "hidden": false, + "id": "relation947079982", + "maxSelect": 1, + "minSelect": 0, + "name": "majorId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "json3353391503", + "maxSize": 0, + "name": "majorIds", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_242989748", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "images", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/jpeg", + "image/vnd.mozilla.apng", + "image/png", + "image/webp", + "video/mp4" + ], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text105650625", + "max": 0, + "min": 0, + "name": "category", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool4208731335", + "name": "isPublic", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3146995638", + "max": 500, + "min": 0, + "name": "cdnUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1442594332", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "majors", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3591849023", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text999323008", + "max": 0, + "min": 0, + "name": "studyTips", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_144009507", + "listRule": "@request.auth.role = \"admin\"", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "mock_exam_configs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3949707534", + "help": "", + "hidden": false, + "id": "relation1040987371", + "maxSelect": 1, + "minSelect": 0, + "name": "subjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "number2254405824", + "max": null, + "min": null, + "name": "duration", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json2604284864", + "maxSize": 0, + "name": "questionTypes", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number2551357351", + "max": null, + "min": null, + "name": "totalQuestions", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1135161748", + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "name": "module_nodes", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2783798401", + "max": 0, + "min": 0, + "name": "moduleId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text284052718", + "max": 0, + "min": 0, + "name": "parentId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2363381545", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 0, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3527180448", + "listRule": "@request.auth.id != \"\" && (userId = @request.auth.id || @request.auth.role = \"admin\")", + "viewRule": "@request.auth.id != \"\" && (userId = @request.auth.id || @request.auth.role = \"admin\")", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "orders", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text581974904", + "max": 0, + "min": 0, + "name": "orderNo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1284837743", + "max": 0, + "min": 0, + "name": "planId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3214635935", + "max": 0, + "min": 0, + "name": "planName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2392944706", + "max": null, + "min": null, + "name": "amount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3957652582", + "max": null, + "min": null, + "name": "days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2603383891", + "max": 0, + "min": 0, + "name": "payMethod", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text777825964", + "max": 0, + "min": 0, + "name": "tradeNo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2840126091", + "max": "", + "min": "", + "name": "paidAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 0, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select4178589564", + "maxSelect": 1, + "name": "payProvider", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "cloudpay", + "xpay" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2444999546", + "max": 0, + "min": 0, + "name": "xpayWxOrderId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text343964646", + "max": 0, + "min": 0, + "name": "xpayChannelOrderId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text38702909", + "max": 0, + "min": 0, + "name": "xpayProductId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3487899667", + "max": null, + "min": null, + "name": "xpayEnv", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json2935021848", + "maxSize": 0, + "name": "xpayRawNotify", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4092854851", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "products", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3402113753", + "max": 0, + "min": 0, + "name": "price", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url917281265", + "name": "link", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "video", + "book", + "material" + ] + }, + { + "help": "", + "hidden": false, + "id": "json1874629670", + "maxSize": 0, + "name": "tags", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2366146245", + "max": 0, + "min": 0, + "name": "cover", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3182169401", + "max": 0, + "min": 0, + "name": "previewIframe", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json276536473", + "maxSize": 0, + "name": "detailImages", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1033065181", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "question_type_groups", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1040987371", + "max": 0, + "min": 0, + "name": "subjectId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1731158936", + "max": 0, + "min": 0, + "name": "displayName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json1496353072", + "maxSize": 0, + "name": "types", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1086039720", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "name": "question_videos", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_4009210445", + "help": "", + "hidden": false, + "id": "relation1262972602", + "maxSelect": 1, + "minSelect": 0, + "name": "questionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_937442202", + "help": "", + "hidden": false, + "id": "relation1972006737", + "maxSelect": 1, + "minSelect": 0, + "name": "videoId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select2602444009", + "maxSelect": 1, + "name": "videoType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "specific", + "general" + ] + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4009210445", + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "name": "questions", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4274335913", + "max": 0, + "min": 0, + "name": "content", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "choice", + "multi", + "judge", + "text", + "image", + "reading", + "terms", + "short_answer", + "composition", + "discuss", + "translation", + "case_analysis", + "brief_analysis", + "calculation", + "analysis_design", + "combination", + "solution" + ] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3949707534", + "help": "", + "hidden": false, + "id": "relation1040987371", + "maxSelect": 1, + "minSelect": 0, + "name": "subjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3292755704", + "help": "", + "hidden": false, + "id": "relation2620853105", + "maxSelect": 1, + "minSelect": 0, + "name": "categoryId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "json3493198471", + "maxSize": 0, + "name": "options", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number2896429690", + "max": null, + "min": null, + "name": "correctOptionIndex", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json4045668934", + "maxSize": 0, + "name": "correctOptionIndices", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text600366971", + "max": 0, + "min": 0, + "name": "answerText", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2284106510", + "max": 0, + "min": 0, + "name": "explanation", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "file1781309708", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/webp", + "image/vnd.mozilla.apng", + "image/png", + "image/jpeg" + ], + "name": "media", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "help": "", + "hidden": false, + "id": "json2563838571", + "maxSize": 0, + "name": "sub_questions", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1162660607", + "max": 0, + "min": 0, + "name": "typeLabel", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text157423495", + "max": 0, + "min": 0, + "name": "nodeId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select132312739", + "maxSelect": 1, + "name": "code_lang", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "c", + "cpp" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1860265896", + "max": 0, + "min": 0, + "name": "code_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2471511001", + "listRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "createRule": "@request.auth.id != \"\" && @request.body.userId = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && userId = @request.auth.id && (@request.body.userId:isset = false || @request.body.userId = @request.auth.id)", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "recent_practices", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "quiz", + "vocabulary", + "handbook" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text962958965", + "max": 0, + "min": 0, + "name": "targetId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2087381559", + "max": 0, + "min": 0, + "name": "targetName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number570552902", + "max": null, + "min": null, + "name": "progress", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date4152561311", + "max": "", + "min": "", + "name": "lastAccessTime", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1716930793", + "max": 0, + "min": 0, + "name": "color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2777894279", + "max": "", + "min": "", + "name": "lastPracticeAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4116282584", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "", + "updateRule": "", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "referral_qrcodes", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3648647130", + "max": 0, + "min": 0, + "name": "scene", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text336246304", + "max": 0, + "min": 0, + "name": "page", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4285051586", + "max": 0, + "min": 0, + "name": "qrcodeUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3644913344", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": "", + "updateRule": "", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "referral_tracks", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1883776062", + "max": 0, + "min": 0, + "name": "eventType", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1588806338", + "max": 0, + "min": 0, + "name": "refCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2599010724", + "maxSelect": 1, + "minSelect": 0, + "name": "refUserId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1435058026", + "maxSelect": 1, + "minSelect": 0, + "name": "targetUserId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1602912115", + "max": 0, + "min": 0, + "name": "source", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2783163181", + "max": 0, + "min": 0, + "name": "ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3236859723", + "max": 0, + "min": 0, + "name": "userAgent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_307120071", + "listRule": "isActive = true", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "region_modules", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2363381545", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1704208859", + "max": 0, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1716930793", + "max": 0, + "min": 0, + "name": "color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4258032067", + "max": 0, + "min": 0, + "name": "textColor", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text46407801", + "max": 0, + "min": 0, + "name": "route", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool3117154012", + "name": "isPrimarySchoolModule", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_859047449", + "listRule": "isActive = true", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "regions", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1997877400", + "max": 0, + "min": 0, + "name": "code", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1609666269", + "max": 0, + "min": 0, + "name": "shortName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "json3565825916", + "maxSize": 0, + "name": "config", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text827839120", + "max": 0, + "min": 0, + "name": "fullName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1704208859", + "max": 0, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2955867648", + "max": 0, + "min": 0, + "name": "pinyin", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool3759076198", + "name": "isHot", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1615648943", + "listRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "viewRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "reports", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_4009210445", + "help": "", + "hidden": false, + "id": "relation1262972602", + "maxSelect": 1, + "minSelect": 0, + "name": "questionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "answer_error", + "content_error", + "typo", + "explanation_bad", + "other" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "pending", + "resolved", + "ignored" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_893211491", + "listRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "viewRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "revenue_daily_stats", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3150471306", + "max": 10, + "min": 0, + "name": "statDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 50, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1232084572", + "max": 30, + "min": 0, + "name": "saleType", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2375761822", + "max": null, + "min": 0, + "name": "realRevenue", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1422664042", + "max": null, + "min": 0, + "name": "orderCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number802582972", + "max": null, + "min": 0, + "name": "codeCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2084495990", + "max": null, + "min": 0, + "name": "codeUsed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1406896833", + "max": null, + "min": 0, + "name": "codeEstimated", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3820463816", + "max": null, + "min": 0, + "name": "estimatedRevenue", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date4021405430", + "max": "", + "min": "", + "name": "rebuiltAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + } + ], + "indexes": [ + "CREATE INDEX idx_revenue_daily_uniq ON revenue_daily_stats (statDate, regionId, saleType)", + "CREATE INDEX idx_revenue_daily_statDate ON revenue_daily_stats (statDate)", + "CREATE INDEX idx_revenue_daily_regionId ON revenue_daily_stats (regionId)" + ], + "system": false + }, + { + "id": "pbc_3591849023", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "schools", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2841076743", + "max": 0, + "min": 0, + "name": "professionalExamDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_307120071", + "help": "", + "hidden": false, + "id": "relation2783798401", + "maxSelect": 1, + "minSelect": 0, + "name": "moduleId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2248487024", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "scoreline_fields", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2427137993", + "max": 0, + "min": 0, + "name": "fieldKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text327886042", + "max": 0, + "min": 0, + "name": "fieldName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select3245739509", + "maxSelect": 1, + "name": "fieldType", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "number", + "text", + "select", + "year" + ] + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3703245907", + "max": 0, + "min": 0, + "name": "unit", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool3198965191", + "name": "isFilter", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool2073665770", + "name": "isRequired", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool477608857", + "name": "isVisible", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number1063427325", + "max": null, + "min": null, + "name": "sortOrder", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json3493198471", + "maxSize": 0, + "name": "options", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4125531906", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool4260119192", + "name": "isTrend", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2584711338", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "scoreline_majors", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_4107968808", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2030837234", + "name": "hasRestriction", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3691013183", + "max": 0, + "min": 0, + "name": "restrictionDesc", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_788775611", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "scoreline_records", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_4107968808", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2584711338", + "help": "", + "hidden": false, + "id": "relation947079982", + "maxSelect": 1, + "minSelect": 0, + "name": "majorId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "number3145888567", + "max": null, + "min": null, + "name": "year", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2682209641", + "max": 0, + "min": 0, + "name": "schoolName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3712467415", + "max": 0, + "min": 0, + "name": "majorName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json2626958263", + "maxSize": 0, + "name": "fieldValues", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4107968808", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "scoreline_schools", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3292170333", + "max": 0, + "min": 0, + "name": "shortName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2363381545", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool3759076198", + "name": "isHot", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2769025244", + "listRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "viewRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "settings", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2873935777", + "max": 0, + "min": 0, + "name": "examDate", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text640955069", + "max": 0, + "min": 0, + "name": "qqAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1420965945", + "max": 0, + "min": 0, + "name": "qqAppKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3556632403", + "max": 0, + "min": 0, + "name": "appUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1643896697", + "max": 0, + "min": 0, + "name": "wechatAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3530747366", + "max": 0, + "min": 0, + "name": "wechatAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool33580598", + "name": "aliyunSmsEnabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text643362359", + "max": 0, + "min": 0, + "name": "aliyunAccessKeyId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3783423574", + "max": 0, + "min": 0, + "name": "aliyunAccessKeySecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2420675515", + "max": 0, + "min": 0, + "name": "aliyunSmsSignName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3095806663", + "max": 0, + "min": 0, + "name": "aliyunSmsTemplateCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2568807145", + "max": 0, + "min": 0, + "name": "wechatWebAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3789055364", + "max": 0, + "min": 0, + "name": "wechatWebAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text264333366", + "max": 0, + "min": 0, + "name": "wechatMpAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1589108542", + "max": 0, + "min": 0, + "name": "wechatMpAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2489133499", + "max": 0, + "min": 0, + "name": "wechatMiniAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2331367925", + "max": 0, + "min": 0, + "name": "wechatMiniAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool2164709491", + "name": "tencentSmsEnabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2156357270", + "max": 0, + "min": 0, + "name": "tencentSecretId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4171788845", + "max": 0, + "min": 0, + "name": "tencentSecretKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text921823053", + "max": 0, + "min": 0, + "name": "tencentSmsAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2524782618", + "max": 0, + "min": 0, + "name": "tencentSmsSignName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1682088066", + "max": 0, + "min": 0, + "name": "tencentSmsTemplateId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2599784205", + "max": null, + "min": null, + "name": "svipDiscountMinQty", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number286114656", + "max": null, + "min": null, + "name": "svipDiscountRate", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text484333636", + "max": 0, + "min": 0, + "name": "svipDiscountLabel", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1734984616", + "max": 0, + "min": 0, + "name": "tencentSmsTemplateLogin", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1725445303", + "max": 0, + "min": 0, + "name": "tencentSmsTemplateRegister", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4039301286", + "max": 0, + "min": 0, + "name": "tencentSmsTemplateBind", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2650585077", + "max": 0, + "min": 0, + "name": "tencentSmsTemplateReset", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1779585412", + "max": 0, + "min": 0, + "name": "xunhuAlipayAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1438352443", + "max": 0, + "min": 0, + "name": "xunhuAlipayAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1454032664", + "max": 0, + "min": 0, + "name": "xunhuWechatAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1488995922", + "max": 0, + "min": 0, + "name": "xunhuWechatAppSecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text852070166", + "max": 0, + "min": 0, + "name": "xunhuNotifyDomain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2025118514", + "max": 0, + "min": 0, + "name": "xunhuReturnUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2083712290", + "max": 0, + "min": 0, + "name": "wxMiniAppId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4152537591", + "max": 0, + "min": 0, + "name": "wxMchId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2190574319", + "max": 0, + "min": 0, + "name": "wxApiV3Key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1115178111", + "max": 0, + "min": 0, + "name": "wxNotifyUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1617171557", + "max": 0, + "min": 0, + "name": "wxSignServiceUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3324348322", + "max": 0, + "min": 0, + "name": "wxPrivateKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text489358763", + "max": 0, + "min": 0, + "name": "wxSerialNo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text849636685", + "max": 0, + "min": 0, + "name": "wxAccessToken", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text454185041", + "max": 0, + "min": 0, + "name": "wxAccessTokenExpires", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1801263526", + "max": 0, + "min": 0, + "name": "xpayOfferId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": true, + "id": "text2002749083", + "max": 0, + "min": 0, + "name": "xpayAppKeyProd", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3861393388", + "max": 0, + "min": 0, + "name": "xpayAppKeySandbox", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select3487899667", + "maxSelect": 1, + "name": "xpayEnv", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "prod", + "sandbox" + ] + }, + { + "help": "", + "hidden": false, + "id": "bool587253240", + "name": "xpayEnabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3293120158", + "max": 0, + "min": 0, + "name": "xpayNotifyToken", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2978584456", + "max": 0, + "min": 0, + "name": "xpayNotifyAesKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number144574031", + "max": null, + "min": null, + "name": "wxAccessTokenExpiry", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3589923086", + "max": 0, + "min": 0, + "name": "defaultReferralUserId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1485687460", + "max": 0, + "min": 0, + "name": "shareImageUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3883025927", + "max": 0, + "min": 0, + "name": "miniAppQrCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2193034978", + "max": 0, + "min": 0, + "name": "homeUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3461432430", + "max": 0, + "min": 0, + "name": "adminUrl", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool642638406", + "name": "s3Enabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3889718361", + "max": 0, + "min": 0, + "name": "s3Provider", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2975733950", + "max": 0, + "min": 0, + "name": "s3Endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1736090422", + "max": 0, + "min": 0, + "name": "s3Region", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2401723622", + "max": 0, + "min": 0, + "name": "s3Bucket", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4171694524", + "max": 0, + "min": 0, + "name": "s3AccessKeyId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2334904912", + "max": 0, + "min": 0, + "name": "s3AccessKeySecret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4134772864", + "max": 0, + "min": 0, + "name": "s3CdnDomain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4159689447", + "max": 0, + "min": 0, + "name": "s3VideoPath", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1139504409", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "smscodes", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1997877400", + "max": 0, + "min": 0, + "name": "code", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2363381545", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date1200295516", + "max": "", + "min": "", + "name": "sendAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "date2358140346", + "max": "", + "min": "", + "name": "expireAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1068355020", + "listRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "viewRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "subject_shares", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3949707534", + "help": "", + "hidden": false, + "id": "relation3508389370", + "maxSelect": 1, + "minSelect": 0, + "name": "sourceSubjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3949707534", + "help": "", + "hidden": false, + "id": "relation1217798977", + "maxSelect": 1, + "minSelect": 0, + "name": "targetSubjectId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3949707534", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.role = 'admin'", + "updateRule": "@request.auth.role = 'admin'", + "deleteRule": "@request.auth.role = 'admin'", + "name": "subjects", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "cultural", + "professional" + ] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3591849023", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1442594332", + "help": "", + "hidden": false, + "id": "relation947079982", + "maxSelect": 1, + "minSelect": 0, + "name": "majorId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "json3353391503", + "maxSize": 0, + "name": "majorIds", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json1464297386", + "maxSize": 0, + "name": "stats", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_307120071", + "help": "", + "hidden": false, + "id": "relation2783798401", + "maxSelect": 1, + "minSelect": 0, + "name": "moduleId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2930532983", + "listRule": "@request.auth.id != \"\" && (couponOnly != true || @request.auth.role = \"admin\" || @request.auth.role = \"superadmin\")", + "viewRule": "@request.auth.id != \"\" && (couponOnly != true || @request.auth.role = \"admin\" || @request.auth.role = \"superadmin\")", + "createRule": "@request.auth.role = \"admin\"", + "updateRule": "@request.auth.role = \"admin\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "svip_plans", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3402113753", + "max": null, + "min": null, + "name": "price", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1549342448", + "max": null, + "min": null, + "name": "originalPrice", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number3957652582", + "max": null, + "min": null, + "name": "days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text196455508", + "max": 0, + "min": 0, + "name": "desc", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text145263146", + "max": 0, + "min": 0, + "name": "perDay", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4277159965", + "max": 0, + "min": 0, + "name": "badge", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool1602849391", + "name": "recommended", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2573357162", + "max": 0, + "min": 0, + "name": "regionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3275170603", + "max": 0, + "min": 0, + "name": "regionName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3654855884", + "max": 0, + "min": 0, + "name": "vpProductId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool3418907143", + "name": "vpEnabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool3965182334", + "name": "couponOnly", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_471168596", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "updateRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "deleteRule": "@request.auth.role = \"admin\" || @request.auth.role = \"superadmin\"", + "name": "tenant_config", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json4222056885", + "maxSize": 0, + "name": "config_json", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text385774305", + "max": 0, + "min": 0, + "name": "updated_by", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3685880632", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "timelines", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "public", + "school" + ] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3591849023", + "help": "", + "hidden": false, + "id": "relation1267398357", + "maxSelect": 1, + "minSelect": 0, + "name": "schoolId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "help": "", + "hidden": false, + "id": "editor1843675174", + "maxSize": 0, + "name": "description", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "help": "", + "hidden": false, + "id": "date1443382381", + "max": "", + "min": "", + "name": "eventDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url917281265", + "name": "link", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_4293750500", + "listRule": "@request.auth.id != \"\" && (@request.auth.role = \"admin\" || userId = @request.auth.id)", + "viewRule": "@request.auth.id != \"\" && (@request.auth.role = \"admin\" || userId = @request.auth.id)", + "createRule": "@request.auth.id != \"\" && @request.body.userId = @request.auth.id", + "updateRule": "@request.auth.id != '' && userId = @request.auth.id", + "deleteRule": "@request.auth.id != '' && userId = @request.auth.id", + "name": "user_answer_records", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1262972602", + "max": 0, + "min": 0, + "name": "questionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2620853105", + "max": 0, + "min": 0, + "name": "categoryId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json437357023", + "maxSize": 0, + "name": "selectedOptions", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "bool2916563067", + "name": "isCorrect", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "date1679402802", + "max": "", + "min": "", + "name": "answeredAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3981798355", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"admin\"", + "name": "user_badges", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 0, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "help": "", + "hidden": false, + "id": "relation4277159965", + "maxSelect": 0, + "minSelect": 0, + "name": "badge", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2784720191", + "maxSelect": 0, + "minSelect": 0, + "name": "granted_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "date4033305153", + "max": "", + "min": "", + "name": "granted_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 0, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1051313589", + "listRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "createRule": "@request.auth.id != \"\" && @request.body.userId = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && userId = @request.auth.id && @request.body.userId:changed = false && @request.body.wordId:changed = false", + "deleteRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "name": "user_word_favorites", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1848244715", + "help": "", + "hidden": false, + "id": "relation1493903008", + "maxSelect": 1, + "minSelect": 0, + "name": "wordId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 0, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2261412156", + "max": "", + "min": "", + "name": "createdAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_word_favorites_user_word\nON user_word_favorites (\"userId\", \"wordId\");\n" + ], + "system": false + }, + { + "id": "pbc_2182898221", + "listRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "createRule": "@request.auth.id != \"\" && @request.body.userId = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && userId = @request.auth.id && (@request.body.userId:isset = false || @request.body.userId = @request.auth.id) && (@request.body.wordId:isset = false || @request.body.wordId = wordId)", + "deleteRule": "@request.auth.id != \"\" && userId = @request.auth.id", + "name": "user_word_progress", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "userId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1848244715", + "help": "", + "hidden": false, + "id": "relation1493903008", + "maxSelect": 1, + "minSelect": 0, + "name": "wordId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "new", + "learning", + "mastered" + ] + }, + { + "help": "", + "hidden": false, + "id": "number4211117972", + "max": null, + "min": null, + "name": "correctCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number1824734194", + "max": null, + "min": null, + "name": "wrongCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "date1593766076", + "max": "", + "min": "", + "name": "lastReviewDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "date247200393", + "max": "", + "min": "", + "name": "nextReviewDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX IF NOT EXISTS idx_user_word_progress_user_next_review\nON user_word_progress (\"userId\", \"nextReviewDate\");\n", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_word_progress_user_word\nON user_word_progress (\"userId\", \"wordId\");\n" + ], + "system": false + }, + { + "id": "pbc_937442202", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": " @request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "updateRule": " @request.auth.role = \"admin\" || @request.auth.role = \"operator\"", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "video_explanations", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url2210121264", + "name": "videoUrl", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url1468393708", + "name": "thumbnailUrl", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "help": "", + "hidden": false, + "id": "number2254405824", + "max": null, + "min": null, + "name": "duration", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json131659572", + "maxSize": 0, + "name": "knowledgeTags", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "bool2834195850", + "name": "isGeneral", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1040987371", + "max": 0, + "min": 0, + "name": "subjectId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3144380399", + "max": null, + "min": null, + "name": "difficulty", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1848244715", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "vocabulary", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3311159078", + "help": "", + "hidden": false, + "id": "relation2996690421", + "maxSelect": 1, + "minSelect": 0, + "name": "unit", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3287381265", + "max": 0, + "min": 0, + "name": "word", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2433687401", + "max": 0, + "min": 0, + "name": "phonetic", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1060237314", + "max": 0, + "min": 0, + "name": "meaning", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1861000095", + "max": 0, + "min": 0, + "name": "example", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1479703618", + "max": 0, + "min": 0, + "name": "exampleTranslation", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3144380399", + "max": null, + "min": null, + "name": "difficulty", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "json1874629670", + "maxSize": 0, + "name": "tags", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX IF NOT EXISTS idx_vocabulary_unit_active_order\nON vocabulary (\"unit\", \"isActive\", \"order\");\n" + ], + "system": false + }, + { + "id": "pbc_3311159078", + "listRule": "", + "viewRule": "", + "createRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "updateRule": "@request.auth.role = 'admin' || @request.auth.role = 'operator'", + "deleteRule": "@request.auth.role = \"admin\"", + "name": "vocabulary_units", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number296229008", + "max": null, + "min": null, + "name": "wordCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool2323052248", + "name": "isActive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_859047449", + "help": "", + "hidden": false, + "id": "relation2573357162", + "maxSelect": 1, + "minSelect": 0, + "name": "regionId", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX IF NOT EXISTS idx_vocabulary_units_region_active_order\nON vocabulary_units (\"regionId\", \"isActive\", \"order\");\n" + ], + "system": false + }, + { + "id": "pbc_4275539003", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_authOrigins", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4228609354", + "max": 0, + "min": 0, + "name": "fingerprint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)" + ], + "system": true + }, + { + "id": "pbc_2281828961", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_externalAuths", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2462348188", + "max": 0, + "min": 0, + "name": "provider", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1044722854", + "max": 0, + "min": 0, + "name": "providerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)", + "CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)" + ], + "system": true + }, + { + "id": "pbc_2279338944", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_mfas", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)" + ], + "system": true + }, + { + "id": "pbc_1638494021", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_otps", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 8, + "help": "", + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 0, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": true, + "id": "text3866985172", + "max": 0, + "min": 0, + "name": "sentTo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)" + ], + "system": true + } +] \ No newline at end of file diff --git a/docs/refactor/README.md b/docs/refactor/README.md new file mode 100644 index 00000000..82aa5e32 --- /dev/null +++ b/docs/refactor/README.md @@ -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,不另起一套后端。 diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md new file mode 100644 index 00000000..8770e8a4 --- /dev/null +++ b/docs/refactor/api-structure.md @@ -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 和其它内容类型应接入同一管线。 diff --git a/docs/refactor/architecture.md b/docs/refactor/architecture.md new file mode 100644 index 00000000..b8f61dad --- /dev/null +++ b/docs/refactor/architecture.md @@ -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,不单独维护另一套后端逻辑。 diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md new file mode 100644 index 00000000..a36359ad --- /dev/null +++ b/docs/refactor/auth-payment-provider-plan.md @@ -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/ diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md new file mode 100644 index 00000000..ba423b0a --- /dev/null +++ b/docs/refactor/backend-progress.md @@ -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 +``` diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md new file mode 100644 index 00000000..e1ad9767 --- /dev/null +++ b/docs/refactor/blueprint-coverage.md @@ -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。 diff --git a/docs/refactor/data-governance.md b/docs/refactor/data-governance.md new file mode 100644 index 00000000..0acea90a --- /dev/null +++ b/docs/refactor/data-governance.md @@ -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 必须幂等。 +- 订单金额、支付流水、权益开通必须可审计。 +- 管理员操作必须写审计日志。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md new file mode 100644 index 00000000..8bb32123 --- /dev/null +++ b/docs/refactor/implementation-status.md @@ -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。 diff --git a/docs/refactor/local-supabase.md b/docs/refactor/local-supabase.md new file mode 100644 index 00000000..773c9f21 --- /dev/null +++ b/docs/refactor/local-supabase.md @@ -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`。 diff --git a/docs/refactor/pocketbase-to-supabase-mapping.md b/docs/refactor/pocketbase-to-supabase-mapping.md new file mode 100644 index 00000000..498be064 --- /dev/null +++ b/docs/refactor/pocketbase-to-supabase-mapping.md @@ -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` 项通常表示旧数据关系缺失,例如旧题目引用了不存在的章节,需要业务确认是否可接受。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..7dd66cfb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5350 @@ +{ + "name": "tianjin-zsb-master", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tianjin-zsb-master", + "version": "1.0.0", + "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" + } + }, + "apps/api": { + "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" + } + }, + "apps/api/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" + } + }, + "apps/api/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/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@google/genai": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", + "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.99.tgz", + "integrity": "sha512-zN4eQlK3eBf7aJBcTHZilpBH3tDekBzPMIWC8r0s94Ecl73XfOyFi4w7yKFMRVUT0lvNQjtOL8YSrwqQj6mZFg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.99", + "@napi-rs/canvas-darwin-arm64": "0.1.99", + "@napi-rs/canvas-darwin-x64": "0.1.99", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.99", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.99", + "@napi-rs/canvas-linux-arm64-musl": "0.1.99", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.99", + "@napi-rs/canvas-linux-x64-gnu": "0.1.99", + "@napi-rs/canvas-linux-x64-musl": "0.1.99", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.99", + "@napi-rs/canvas-win32-x64-msvc": "0.1.99" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.99.tgz", + "integrity": "sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.99.tgz", + "integrity": "sha512-lupMDMy1+H38dhyCcLirOKKVUyzzlxi7j7rGPLI3vViMHOoPjcXO1b10ivy+ad+q6MiwHfoLjKTCoLke5ySOBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.99.tgz", + "integrity": "sha512-fdz02t4w8n6Ii/rYhWig6STb/zcTmCC/6YZTGmjoDeidDwn9Wf0ukQVynhCPEs29vqUc66wHZKsuIgMs9tycCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.99.tgz", + "integrity": "sha512-w4FwVwlNo00ezeRhfY62IVIyt6G3u8wodkPtiqWc52BUHx+VDBUM2vkS3ogfANaLI7hnf3s6WK4LyZVUjBg1lA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.99.tgz", + "integrity": "sha512-8JvHeexKQ8c7g0q7YJ29NVQwnf1ePghP9ys9ZN0R0qzyqJQ9Uw6N9qnDINArlm3IYHexB7LjzArIfhQiqSDGvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.99.tgz", + "integrity": "sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.99.tgz", + "integrity": "sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.99.tgz", + "integrity": "sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.99.tgz", + "integrity": "sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.99.tgz", + "integrity": "sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.99.tgz", + "integrity": "sha512-plMYGVbc/vmmPF9MtmHbwNk1rL1Aj53vQZt+Gnv1oZn6gmd9jEHHJ0n9Nd2nxa5sKH7TS5IjkCDM6289O0d6PQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.107.0.tgz", + "integrity": "sha512-930MojHei14+PrGNF0QzlPJAjOYilCofgO9aTtKiQ6x36ZUI7dc4ujl5VzccC/19ex/f4ird0lH1d2U92MwDqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.107.0.tgz", + "integrity": "sha512-qcnichkHiCCNybtCqoeqYO6q8WuTxilaah18QilZskEM+zCSdV5rOXXfeF1BPexRxsvGYbghZ+fEQfWp7+lOUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.107.0.tgz", + "integrity": "sha512-an0dsOhPcLQXZ0sFEBm6NU1HFUjXCStHxetfJvgt4u7SiH/EyDMLfDvV7W9ef56bgG+3hoWHtih2Kplj3cecQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.107.0.tgz", + "integrity": "sha512-5se1bYRIzivL+ehImnwFjBpzKZ+AwLS6/cBg6lvJOJdWatuaK9Edu6wOZAmPjVMy4vZRJ27tdL+3F7N+Vk64AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.107.0.tgz", + "integrity": "sha512-8ttnpfBFEXk0JFmH6gw8ZCVcMa/UNH1sD6iG9PQLbK2eXdZ6kDIzGs5g+CEvxZ3j6HxEIYn9h9rfxoqGoBUgwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.107.0.tgz", + "integrity": "sha512-gXDzvDsmmJw9HbeVRFD6xboGwbu/cpBp84ZESEcXKyB8Onvs3igWKwoXMofx+ppFM1EaZb/flTwJ77b19EMD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.107.0.tgz", + "integrity": "sha512-V/4q5dbDgBQXhDAJaHamTm9u/kY2UJ4nNibVKOYMaLh9q7H74b8unteJ+rYHSwWZy0EIT8DTDryGP6xatKQicw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.107.0.tgz", + "integrity": "sha512-ciDDFUGHt6bFjtVD00cojFhB5oscZAyjPo2vg94RGUeKHXx1zo/UzfMvUlCA32y5b5KONZ4TKStCHhlFK7Clrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tiku-saas/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@tiku-saas/config": { + "resolved": "packages/config", + "link": true + }, + "node_modules/@tiku-saas/db": { + "resolved": "packages/db", + "link": true + }, + "node_modules/@tiku-saas/domain": { + "resolved": "packages/domain", + "link": true + }, + "node_modules/@tiku-saas/import-pocketbase": { + "resolved": "scripts/import-pocketbase", + "link": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.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/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/docx": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", + "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.330.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.330.0.tgz", + "integrity": "sha512-CQwY+Fpbt2kxCoVhuN0RCZDCYlbYnqB870Bl/vIQf3ER/cnDDQ6moLmEkguRyruAUGd4j3Lc4mtnJosXnqHheA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.96", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, + "node_modules/perfect-freehand": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/perfect-freehand/-/perfect-freehand-1.2.3.tgz", + "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==", + "license": "MIT" + }, + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pocketbase": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.21.5.tgz", + "integrity": "sha512-bnI/uinnQps+ElSlzxkc4yvwuSFfKcoszDtXH/4QT2FhGq2mJVUvDlxn+rjRXVntUjPfmMG5LEPZ1eGqV6ssog==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "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/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-to-print": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.3.0.tgz", + "integrity": "sha512-7j9GIeNZA9glZlbv9mIbIHDOOx+WYfRMbJzh04NiSKjdaeGkxJuKjJQrtRuNKtt5AvEVVjrLCPokZ9yJX51Fvg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "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/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supabase": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.107.0.tgz", + "integrity": "sha512-qYAbm3D//buhnY5v4L//IrBQhowN2Jd2kx16hNyOfu0+TVuWVIR1L5WsS/YOk+UJh3Ch5ABmqKebWpwn1p0ysg==", + "dev": true, + "license": "MIT", + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.107.0", + "@supabase/cli-darwin-x64": "2.107.0", + "@supabase/cli-linux-arm64": "2.107.0", + "@supabase/cli-linux-arm64-musl": "2.107.0", + "@supabase/cli-linux-x64": "2.107.0", + "@supabase/cli-linux-x64-musl": "2.107.0", + "@supabase/cli-windows-arm64": "2.107.0", + "@supabase/cli-windows-x64": "2.107.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/tsx/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/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.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "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" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "packages/config": { + "name": "@tiku-saas/config", + "version": "0.1.0" + }, + "packages/db": { + "name": "@tiku-saas/db", + "version": "0.1.0", + "dependencies": { + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/pg": "^8.15.4" + } + }, + "packages/domain": { + "name": "@tiku-saas/domain", + "version": "0.1.0" + }, + "scripts/import-pocketbase": { + "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" + } + }, + "scripts/import-pocketbase/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" + } + }, + "scripts/import-pocketbase/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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..59dcbfb0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/config/package-lock.json b/packages/config/package-lock.json new file mode 100644 index 00000000..edbce297 --- /dev/null +++ b/packages/config/package-lock.json @@ -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" + } + } +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..02122042 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,8 @@ +{ + "name": "@tiku-saas/config", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/config/src/index.js b/packages/config/src/index.js new file mode 100644 index 00000000..287bf0ea --- /dev/null +++ b/packages/config/src/index.js @@ -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); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 00000000..8da27cc7 --- /dev/null +++ b/packages/config/src/index.ts @@ -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); +} diff --git a/packages/db/package-lock.json b/packages/db/package-lock.json new file mode 100644 index 00000000..fef3dcfb --- /dev/null +++ b/packages/db/package-lock.json @@ -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" + } + } + } +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 00000000..75ed526b --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/src/index.js b/packages/db/src/index.js new file mode 100644 index 00000000..7ca2da55 --- /dev/null +++ b/packages/db/src/index.js @@ -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; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 00000000..7b93a561 --- /dev/null +++ b/packages/db/src/index.ts @@ -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(pool: pg.Pool, sql: string, params: unknown[] = []): Promise { + const result = await pool.query(sql, params); + return result.rows as T[]; +} + +export async function queryOne(pool: pg.Pool, sql: string, params: unknown[] = []): Promise { + const rows = await query(pool, sql, params); + return rows[0] ?? null; +} diff --git a/packages/domain/package-lock.json b/packages/domain/package-lock.json new file mode 100644 index 00000000..821c1171 --- /dev/null +++ b/packages/domain/package-lock.json @@ -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" + } + } +} diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 00000000..e9d31173 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,8 @@ +{ + "name": "@tiku-saas/domain", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/domain/src/index.js b/packages/domain/src/index.js new file mode 100644 index 00000000..56d85822 --- /dev/null +++ b/packages/domain/src/index.js @@ -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'; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 00000000..f6a340ad --- /dev/null +++ b/packages/domain/src/index.ts @@ -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'; +} diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js new file mode 100644 index 00000000..0e5e1538 --- /dev/null +++ b/scripts/api-integration-test.js @@ -0,0 +1,1138 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; + +const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; +const PARTNER_TENANT_ID = process.env.PARTNER_TENANT_ID || '00000000-0000-0000-0000-000000000901'; +const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101'; +const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000-0000-0000-000000000102'; +const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103'; +const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104'; +const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105'; +const START_SERVER = process.argv.includes('--start-server'); + +const ids = { + region: '00000000-0000-0000-0000-000000000301', + subject: '00000000-0000-0000-0000-000000000501', + category: '00000000-0000-0000-0000-000000000601', + question: '00000000-0000-0000-0000-000000000401', + vocabularyUnit: '00000000-0000-0000-0000-000000000811', + vocabularyWord: '00000000-0000-0000-0000-000000000812', + scorelineSchool: '00000000-0000-0000-0000-000000000831', +}; + +let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787'; +let serverProcess = null; +let serverLogs = ''; + +function buildUrl(path, query = {}) { + const target = new URL(path, apiBase); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') { + target.searchParams.set(key, String(value)); + } + } + return target; +} + +async function request(path, options = {}) { + const response = await fetch(buildUrl(path, options.query), { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || MAIN_TENANT_ID }), + ...(options.userId === false ? {} : { 'x-user-id': options.userId || USER_ID }), + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (options.expectStatus) { + assert.equal(response.status, options.expectStatus, `${options.method || 'GET'} ${path} should return ${options.expectStatus}`); + return payload; + } + if (!response.ok) { + throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); + } + return payload; +} + +async function check(name, fn) { + await fn(); + console.log(`[PASS] ${name}`); +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForHealth(timeoutMs = 12_000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const payload = await request('/health', { userId: false }); + if (payload.ok) return; + } catch (error) { + lastError = error; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`); +} + +async function startServerIfNeeded() { + if (!START_SERVER) return; + const port = Number(process.env.TEST_API_PORT || 0) || await getFreePort(); + apiBase = `http://127.0.0.1:${port}`; + serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + PORT: String(port), + DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + serverProcess.stdout.on('data', chunk => { + serverLogs += chunk.toString(); + }); + serverProcess.stderr.on('data', chunk => { + serverLogs += chunk.toString(); + }); + + await waitForHealth(); +} + +function stopServer() { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +async function testCatalogAndLearning() { + const questions = await request('/api/catalog/questions', { query: { limit: 20 } }); + const question = questions.items?.find(item => item.id === ids.question); + assert.ok(question, 'main tenant should return smoke question'); + assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker'); + + const session = await request('/api/learning/practice-sessions', { + method: 'POST', + body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question }, + }); + assert.ok(session.item?.id, 'practice session should be created'); + + const answer = await request('/api/learning/answers', { + method: 'POST', + body: { + userId: USER_ID, + questionId: ids.question, + selectedOptions: ['0'], + practiceSessionId: session.item.id, + }, + }); + assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false'); + + const wrong = await request('/api/learning/wrong-questions', { query: { status: 'all' } }); + assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question'); +} + +async function testProfile() { + const payload = await request('/api/profile/me'); + assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user'); + assert.ok(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats'); + assert.ok(Array.isArray(payload.item?.recentPractices), 'profile should include recent practices'); +} + +async function testScoreline() { + const fields = await request('/api/scoreline/fields', { query: { regionId: ids.region } }); + assert.ok(fields.items?.some(item => item.fieldKey === 'minScore'), 'scoreline fields should include minScore'); + + const schools = await request('/api/scoreline/schools', { query: { regionId: ids.region } }); + assert.ok(schools.items?.some(item => item.id === ids.scorelineSchool), 'scoreline school should exist'); + + const records = await request('/api/scoreline/records', { query: { regionId: ids.region, pageSize: 5 } }); + assert.ok(records.total >= 1, 'scoreline records should have data'); + assert.ok(records.items?.some(item => item.fieldValues?.minScore === 188), 'scoreline record should include dynamic field values'); + + const years = await request('/api/scoreline/years', { query: { regionId: ids.region } }); + assert.ok(years.items?.includes(2026), 'scoreline years should include 2026'); +} + +async function testVideos() { + const single = await request(`/api/questions/${ids.question}/videos`); + assert.ok(single.total >= 1, 'question should have videos'); + + const batch = await request('/api/questions/videos/batch', { + method: 'POST', + body: { questionIds: [ids.question] }, + }); + assert.equal(batch.data?.[ids.question]?.hasVideo, true, 'batch video lookup should mark question as having video'); + + const search = await request('/api/videos/search', { query: { tags: '烟测' } }); + assert.ok(search.videos?.some(item => item.title === '烟测题目视频讲解'), 'general video search should find smoke video'); +} + +async function testVocabulary() { + const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } }); + assert.ok(stats.item?.totalWords >= 1, 'word stats should count smoke word'); + + const progress = await request('/api/learning/vocabulary/progress', { + method: 'POST', + body: { userId: USER_ID, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 }, + }); + assert.equal(progress.item?.status, 'mastered', 'word progress should update to mastered'); + + const favorite = await request('/api/learning/vocabulary/favorites', { + method: 'POST', + body: { userId: USER_ID, wordId: ids.vocabularyWord, favorite: true }, + }); + assert.equal(favorite.favorite, true, 'word favorite should be true'); + + const favorites = await request('/api/learning/vocabulary/favorites', { query: { unitId: ids.vocabularyUnit } }); + assert.ok(favorites.items?.some(item => item.wordId === ids.vocabularyWord), 'favorite list should include smoke word'); +} + +async function testCommerce() { + const orders = await request('/api/commerce/orders'); + assert.ok(orders.items?.some(item => item.orderNo === 'SMOKE-ORDER-20260621'), 'orders should include smoke order'); + + const redeemed = await request('/api/commerce/activation-codes/redeem', { + method: 'POST', + body: { code: 'SMOKE20260621', regionId: ids.region }, + }); + assert.ok(redeemed.item?.entitlement?.id, 'activation code should grant an entitlement'); + + const entitlements = await request('/api/commerce/entitlements'); + assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list'); + assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary'); + assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP'); +} + +async function testTenantIsolation() { + const partnerQuestions = await request('/api/catalog/questions', { + tenantId: PARTNER_TENANT_ID, + query: { limit: 20 }, + }); + assert.ok(!partnerQuestions.items?.some(item => item.id === ids.question), 'partner tenant must not see main tenant question'); + + const partnerProfile = await request('/api/profile/me', { + tenantId: PARTNER_TENANT_ID, + expectStatus: 404, + }); + assert.equal(partnerProfile.code, 'PROFILE_NOT_FOUND', 'partner tenant must not see main tenant student profile'); + + const partnerScoreline = await request('/api/scoreline/records', { + tenantId: PARTNER_TENANT_ID, + query: { regionId: ids.region }, + }); + assert.equal(partnerScoreline.total, 0, 'partner tenant must not see main tenant scoreline records'); + + const partnerVideos = await request('/api/questions/videos/batch', { + tenantId: PARTNER_TENANT_ID, + method: 'POST', + body: { questionIds: [ids.question] }, + }); + assert.equal(partnerVideos.data?.[ids.question], undefined, 'partner tenant must not see main tenant question videos'); + + const partnerWordStats = await request('/api/learning/vocabulary/stats', { + tenantId: PARTNER_TENANT_ID, + query: { unitId: ids.vocabularyUnit }, + }); + assert.equal(partnerWordStats.item?.totalWords, 0, 'partner tenant must not see main tenant vocabulary words'); +} + +async function testTenantContentAdmin() { + const denied = await request('/api/tenant-content/vocabulary-units', { + method: 'PUT', + body: { name: '学生不能写入的单元' }, + expectStatus: 403, + }); + assert.equal(denied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not write tenant content'); + + const unit = await request('/api/tenant-content/vocabulary-units', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + regionId: ids.region, + name: '集成测试单词单元', + description: '租户后台内容维护集成测试', + order: 99, + }, + }); + assert.ok(unit.item?.id, 'tenant admin should create vocabulary unit'); + + const word = await request('/api/tenant-content/vocabulary-words', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + unitId: unit.item.id, + word: 'commercial', + phonetic: '/kəˈmɜːrʃl/', + meaning: '商业的', + tags: ['integration'], + }, + }); + assert.equal(word.item?.word, 'commercial', 'tenant admin should create vocabulary word'); + + const video = await request('/api/tenant-content/videos', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + title: '集成测试视频', + videoUrl: 'https://example.test/videos/integration.mp4', + knowledgeTags: ['integration'], + isGeneral: true, + }, + }); + assert.ok(video.item?.id, 'tenant admin should create video'); + + const question = await request('/api/tenant-content/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + subjectId: '00000000-0000-0000-0000-000000000501', + categoryId: '00000000-0000-0000-0000-000000000601', + type: 'choice', + typeLabel: '单选题', + difficulty: 2, + content: '集成测试题:2 + 2 = ?', + options: [ + { label: 'A', text: '3' }, + { label: 'B', text: '4' }, + ], + correctOptionIndex: 1, + correctOptionIndices: [1], + answerText: '4', + explanation: '基础加法。', + status: 'published', + }, + }); + assert.ok(question.item?.id, 'tenant admin should create question'); + assert.equal(question.item?.currentVersion?.correctOptionIndex, 1, 'created question should have a version'); + + const binding = await request('/api/tenant-content/question-videos', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { questionId: question.item.id, videoId: video.item.id, videoType: 'specific' }, + }); + assert.equal(binding.item?.questionId, question.item.id, 'tenant admin should bind question video'); + + const scoreSchool = await request('/api/tenant-content/scoreline/schools', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { regionId: ids.region, name: '集成测试学院', shortName: '集测学院', isHot: true }, + }); + assert.ok(scoreSchool.item?.id, 'tenant admin should create scoreline school'); + + const scoreMajor = await request('/api/tenant-content/scoreline/majors', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { regionId: ids.region, schoolId: scoreSchool.item.id, name: '软件工程' }, + }); + assert.ok(scoreMajor.item?.id, 'tenant admin should create scoreline major'); + + const scoreRecord = await request('/api/tenant-content/scoreline/records', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + regionId: ids.region, + schoolId: scoreSchool.item.id, + majorId: scoreMajor.item.id, + year: 2026, + schoolName: '集成测试学院', + majorName: '软件工程', + fieldValues: { minScore: 199 }, + }, + }); + assert.equal(scoreRecord.item?.fieldValues?.minScore, 199, 'tenant admin should create scoreline record'); + + const handbookSubject = await request('/api/tenant-content/handbook-subjects', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { regionId: ids.region, name: '集成测试手册', type: 'guide' }, + }); + assert.ok(handbookSubject.item?.id, 'tenant admin should create handbook subject'); + + const handbookChapter = await request('/api/tenant-content/handbook-chapters', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { subjectId: handbookSubject.item.id, name: '第一章' }, + }); + assert.ok(handbookChapter.item?.id, 'tenant admin should create handbook chapter'); + + const handbookEntry = await request('/api/tenant-content/handbook-entries', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { chapterId: handbookChapter.item.id, title: '商用交付标准', content: '内容维护必须可测试。' }, + }); + assert.equal(handbookEntry.item?.title, '商用交付标准', 'tenant admin should create handbook entry'); + + const partnerWrite = await request('/api/tenant-content/question-videos', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { questionId: question.item.id, videoId: video.item.id }, + expectStatus: 403, + }); + assert.equal(partnerWrite.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'admin user must not administer another tenant without membership'); +} + +async function testTenantContentAssetsAndImports() { + const deniedAsset = await request('/api/tenant-content/assets', { + method: 'PUT', + body: { title: '学生不能上传资料', cdnUrl: 'https://example.test/denied.pdf' }, + expectStatus: 403, + }); + assert.equal(deniedAsset.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not manage content assets'); + + const upload = await request('/api/tenant-content/assets/sign-upload', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + fileName: 'integration-resource.pdf', + assetType: 'pdf', + storageProvider: 'local_dev', + mimeType: 'application/pdf', + }, + }); + assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft'); + assert.ok(upload.upload?.objectKey?.includes(MAIN_TENANT_ID), 'upload object key should be tenant scoped'); + + const asset = await request('/api/tenant-content/assets', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + title: '集成测试 SVIP PDF 资料', + assetType: 'pdf', + storageProvider: 'external_url', + cdnUrl: 'https://example.test/resources/integration.pdf', + previewUrl: 'https://example.test/resources/integration-preview.pdf', + fileName: 'integration.pdf', + mimeType: 'application/pdf', + fileSizeBytes: 2048, + visibility: 'svip', + regionId: ids.region, + subjectId: ids.subject, + categoryId: ids.category, + metadata: { source: 'api-integration' }, + }, + }); + assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset'); + + const adminAssets = await request('/api/tenant-content/assets', { + userId: TENANT_ADMIN_USER_ID, + query: { assetType: 'pdf', regionId: ids.region }, + }); + assert.ok(adminAssets.items?.some(item => item.id === asset.item.id), 'tenant admin should list own asset'); + assert.ok(!JSON.stringify(adminAssets).includes('apiV3Key'), 'asset list should not expose unrelated secrets'); + + const publicAssets = await request('/api/catalog/assets', { + userId: false, + query: { assetType: 'pdf', regionId: ids.region, includeLocked: true }, + }); + assert.ok(!publicAssets.items?.some(item => item.id === asset.item.id), 'anonymous catalog should not list locked svip asset'); + + const lockedAssets = await request('/api/catalog/assets', { + query: { assetType: 'pdf', regionId: ids.region, includeLocked: true }, + }); + assert.ok(lockedAssets.items?.some(item => item.id === asset.item.id), 'student catalog should list locked svip asset'); + + const download = await request('/api/catalog/assets/download', { + query: { assetId: asset.item.id }, + }); + assert.equal(download.item?.id, asset.item.id, 'svip student should receive asset download'); + assert.equal(download.access?.svip, true, 'asset download should report svip access'); + assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url'); + + const partnerAssetList = await request('/api/tenant-content/assets', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerAssetList.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'asset admin must be tenant isolated'); + + const deniedImport = await request('/api/tenant-content/imports/preview/questions', { + method: 'POST', + body: { + subjectId: ids.subject, + categoryId: ids.category, + items: [{ type: 'choice', content: '学生不能预览导入', options: ['A', 'B'], correctOptionIndices: [0] }], + }, + expectStatus: 403, + }); + assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import'); + + const invalidPreview = await request('/api/tenant-content/imports/preview/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'invalid-question-import.json', + subjectId: ids.subject, + categoryId: ids.category, + regionId: ids.region, + items: [ + { + type: 'choice', + content: '错误导入题:缺少选项和答案', + options: [], + correctOptionIndices: [], + }, + ], + }, + }); + assert.equal(invalidPreview.job?.errorCount > 0, true, 'invalid preview should record errors'); + assert.ok(invalidPreview.issues?.some(issue => issue.code === 'OPTIONS_REQUIRED'), 'invalid preview should include option issue'); + + const invalidIssues = await request('/api/tenant-content/imports/issues', { + userId: TENANT_ADMIN_USER_ID, + query: { jobId: invalidPreview.job.id }, + }); + assert.ok(invalidIssues.items?.some(item => item.code === 'OPTIONS_REQUIRED'), 'import issues API should return validation issues'); + + const rejectedImport = await request('/api/tenant-content/imports/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { previewJobId: invalidPreview.job.id }, + expectStatus: 409, + }); + assert.equal(rejectedImport.code, 'IMPORT_HAS_ERRORS', 'invalid import should be rejected without allowPartial'); + + const validPreview = await request('/api/tenant-content/imports/preview/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'valid-question-import.json', + subjectId: ids.subject, + categoryId: ids.category, + regionId: ids.region, + items: [ + { + legacyId: 'integration-import-choice-001', + type: 'choice', + typeLabel: '单选题', + content: '批量导入题:企业级 SaaS 应优先使用哪种数据库?', + options: ['SQLite', 'PostgreSQL', '纯 JSON 文件', '浏览器缓存'], + correctOptionIndices: [1], + explanation: 'PostgreSQL 更适合多租户商用场景。', + difficulty: 2, + tags: ['integration', 'import'], + }, + { + legacyId: 'integration-import-reading-001', + type: 'reading', + content: '阅读材料:多租户系统需要隔离租户数据。', + sub_questions: [ + { + type: 'choice', + content: '多租户系统最重要的边界是什么?', + options: ['颜色主题', '数据隔离', '页面动画', '字体大小'], + correctOptionIndices: [1], + }, + ], + tags: ['integration', 'reading'], + }, + ], + }, + }); + assert.equal(validPreview.job?.errorCount, 0, 'valid preview should have no errors'); + assert.equal(validPreview.job?.validCount, 2, 'valid preview should count valid rows'); + + const imported = await request('/api/tenant-content/imports/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { previewJobId: validPreview.job.id }, + }); + assert.equal(imported.item?.status, 'completed', 'valid import should complete'); + assert.equal( + (imported.item?.insertedCount || 0) + (imported.item?.updatedCount || 0) + (imported.item?.skippedCount || 0), + 2, + 'valid import should process all valid questions idempotently', + ); + + const jobs = await request('/api/tenant-content/imports', { + userId: TENANT_ADMIN_USER_ID, + query: { importType: 'questions', limit: 10 }, + }); + assert.ok(jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed'), 'import job list should include completed job'); + + const importedQuestions = await request('/api/catalog/questions', { + query: { categoryId: ids.category, limit: 100 }, + }); + assert.ok( + importedQuestions.items?.some(item => item.content === '批量导入题:企业级 SaaS 应优先使用哪种数据库?'), + 'catalog should expose imported question', + ); + + const partnerImports = await request('/api/tenant-content/imports', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerImports.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'import jobs must be tenant isolated'); +} + +async function testTenantAdminOps() { + const denied = await request('/api/tenant-admin/branding', { + method: 'PUT', + body: { brandName: '学生不能改品牌' }, + expectStatus: 403, + }); + assert.equal(denied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant admin config'); + + const overview = await request('/api/tenant-admin/overview', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.equal(overview.item?.id, MAIN_TENANT_ID, 'tenant admin overview should belong to main tenant'); + + const branding = await request('/api/tenant-admin/branding', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + brandName: '集成测试品牌', + shortName: '集测题库', + slogan: '一套前端服务多个合作商', + logoUrl: 'https://example.test/logo.png', + theme: { primaryColor: '#0f766e' }, + publicAssets: { h5Logo: 'https://example.test/h5-logo.png' }, + }, + }); + assert.equal(branding.item?.brandName, '集成测试品牌', 'tenant admin should update branding'); + + const publicSecretRejected = await request('/api/tenant-admin/auth-providers', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'wechat-miniapp', + status: 'testing', + configPublic: { + appId: 'wx-smoke-appid', + appSecret: 'must-not-be-public', + }, + }, + expectStatus: 400, + }); + assert.equal(publicSecretRejected.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'public config should reject secret-like keys'); + + const authProvider = await request('/api/tenant-admin/auth-providers', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'wechat-miniapp', + displayName: '微信小程序登录', + status: 'testing', + configPublic: { + appId: 'wx-smoke-appid', + envVersion: 'trial', + }, + secret: { + secretValue: 'wechat-app-secret-smoke', + }, + }, + }); + assert.equal(authProvider.item?.provider, 'wechat-miniapp', 'tenant admin should upsert auth provider'); + assert.equal(authProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-miniapp', 'auth provider should expose only secretRef'); + assert.equal(authProvider.item?.secret?.hasSecretValue, true, 'auth provider should report masked secret status'); + assert.ok(!JSON.stringify(authProvider).includes('wechat-app-secret-smoke'), 'auth provider response must not include secret plaintext'); + + const paymentAccount = await request('/api/tenant-admin/payment-accounts', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'wechat_pay', + mode: 'tenant_collect', + displayName: '合作商微信商户', + status: 'pending', + configPublic: { + merchantId: '1900000001', + appId: 'wx-smoke-appid', + notifyUrl: 'https://pay.example.test/wechat/notify', + }, + secret: { + secretJson: { + apiV3Key: 'wechat-pay-api-v3-key', + merchantSerialNo: 'serial-smoke', + }, + }, + }, + }); + assert.equal(paymentAccount.item?.provider, 'wechat_pay', 'tenant admin should upsert payment account'); + assert.equal(paymentAccount.item?.configPublic?.secretRef, 'app_private.tenant_secrets:payment:wechat_pay', 'payment account should expose only secretRef'); + assert.ok(!JSON.stringify(paymentAccount).includes('wechat-pay-api-v3-key'), 'payment response must not include secret json values'); + + const secrets = await request('/api/tenant-admin/secrets', { + userId: TENANT_ADMIN_USER_ID, + query: { scope: 'payment' }, + }); + assert.ok(secrets.items?.some(item => item.secretRef === 'app_private.tenant_secrets:payment:wechat_pay'), 'masked secret list should include payment secretRef'); + assert.ok(!JSON.stringify(secrets).includes('wechat-pay-api-v3-key'), 'secret list must not leak secret json values'); + + const banner = await request('/api/tenant-admin/banners', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + regionId: ids.region, + title: '集成测试活动', + subtitle: '租户自有活动位', + content: '合作商可配置自己的首页 Banner', + buttonText: '查看会员', + buttonLink: '/store', + order: 7, + isActive: true, + }, + }); + assert.equal(banner.item?.title, '集成测试活动', 'tenant admin should upsert banner'); + + const faq = await request('/api/tenant-admin/faqs', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + regionId: ids.region, + question: '合作商能否配置自己的支付商户?', + answer: '可以,密钥写入私密表,前端只拿公开配置。', + order: 8, + }, + }); + assert.equal(faq.item?.question, '合作商能否配置自己的支付商户?', 'tenant admin should upsert faq'); + + const announcement = await request('/api/tenant-admin/announcements', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + content: '集成测试公告', + link: '/announcements/integration', + bgColor: '#f0fdfa', + order: 9, + }, + }); + assert.equal(announcement.item?.content, '集成测试公告', 'tenant admin should upsert announcement'); + + const publicBanners = await request('/api/catalog/banners', { + query: { regionId: ids.region }, + }); + assert.ok(publicBanners.items?.some(item => item.title === '集成测试活动'), 'public catalog should expose active tenant banner'); + + const batch = await request('/api/tenant-admin/code-batches', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + name: '集成测试激活码批次', + saleType: 'saas_partner', + channel: 'offline', + campaignName: 'partner-onboarding', + defaultUnitPriceCents: 9900, + days: 365, + regionId: ids.region, + remark: '用于租户后台接口测试', + }, + }); + assert.ok(batch.item?.id, 'tenant admin should create code batch'); + + const generated = await request('/api/tenant-admin/activation-codes/generate', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + batchId: batch.item.id, + count: 2, + prefix: 'IT', + soldTo: 'integration-partner', + }, + }); + assert.equal(generated.count, 2, 'tenant admin should batch generate activation codes'); + assert.ok(generated.items?.every(item => String(item.code).startsWith('IT')), 'generated codes should use prefix'); + + const activationCode = await request('/api/tenant-admin/activation-codes', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'IT-MANUAL-001', + days: 30, + batchId: batch.item.id, + soldTo: 'manual-customer', + remark: 'manual integration code', + }, + }); + assert.equal(activationCode.item?.code, 'IT-MANUAL-001', 'tenant admin should upsert activation code'); + + const coupon = await request('/api/tenant-admin/coupons', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'IT-COUPON-001', + planId: '00000000-0000-0000-0000-000000000201', + discountType: 'fixed', + discountValue: 10, + maxUses: 100, + source: 'integration-test', + }, + }); + assert.equal(coupon.item?.code, 'IT-COUPON-001', 'tenant admin should upsert coupon'); + + const partnerDenied = await request('/api/tenant-admin/auth-providers', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'qq-oauth', + configPublic: { appId: 'qq-smoke' }, + }, + expectStatus: 403, + }); + assert.equal(partnerDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not administer another tenant without membership'); +} + +async function testTenantMemberPermissionsAndAudit() { + const permissionMatrix = await request('/api/tenant-admin/permissions', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission'); + assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); + + const operator = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_OPERATOR_USER_ID, + username: 'integration_operator', + phone: '13800000003', + name: 'Integration Operator', + role: 'tenant_operator', + status: 'active', + permissions: { + 'marketing:*': true, + 'tenant:payment:*': false, + }, + }, + }); + assert.equal(operator.item?.role, 'tenant_operator', 'tenant admin should create operator membership'); + + const members = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + query: { keyword: 'Integration Operator' }, + }); + assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID), 'member list should find operator'); + + const operatorBanner = await request('/api/tenant-admin/banners', { + userId: TENANT_OPERATOR_USER_ID, + method: 'PUT', + body: { + regionId: ids.region, + title: '运营角色活动位', + content: '运营成员可以维护活动内容', + order: 17, + }, + }); + assert.equal(operatorBanner.item?.title, '运营角色活动位', 'operator should write marketing content'); + + const operatorPaymentDenied = await request('/api/tenant-admin/payment-accounts', { + userId: TENANT_OPERATOR_USER_ID, + method: 'PUT', + body: { + provider: 'alipay', + mode: 'tenant_collect', + configPublic: { appId: 'alipay-appid' }, + }, + expectStatus: 403, + }); + assert.equal(operatorPaymentDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator should not write payment config'); + + const grantAdminDenied = await request('/api/tenant-admin/members', { + userId: TENANT_OPERATOR_USER_ID, + method: 'PUT', + body: { + userId: TENANT_SALES_USER_ID, + role: 'tenant_admin', + permissions: { '*': true }, + }, + expectStatus: 403, + }); + assert.equal(grantAdminDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator should not manage members without permission'); + + const sales = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_SALES_USER_ID, + username: 'integration_sales', + phone: '13800000004', + name: 'Integration Sales', + role: 'sales', + status: 'active', + permissions: { + 'codes:*': true, + 'coupons:*': true, + }, + }, + }); + assert.equal(sales.item?.role, 'sales', 'tenant admin should create sales membership'); + + const salesBatch = await request('/api/tenant-admin/code-batches', { + userId: TENANT_SALES_USER_ID, + method: 'PUT', + body: { + name: '销售角色批次', + saleType: 'sales', + defaultUnitPriceCents: 19900, + days: 180, + }, + }); + assert.ok(salesBatch.item?.id, 'sales role should create code batch'); + + const salesBrandingDenied = await request('/api/tenant-admin/branding', { + userId: TENANT_SALES_USER_ID, + method: 'PUT', + body: { + brandName: '销售不能改品牌', + }, + expectStatus: 403, + }); + assert.equal(salesBrandingDenied.code, 'TENANT_PERMISSION_REQUIRED', 'sales role should not update branding'); + + const disableSales = await request('/api/tenant-admin/members/disable', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { membershipId: sales.item.id }, + }); + assert.equal(disableSales.item?.status, 'disabled', 'tenant admin should disable sales membership'); + + const disabledSalesDenied = await request('/api/tenant-admin/code-batches', { + userId: TENANT_SALES_USER_ID, + method: 'PUT', + body: { + name: '禁用后不能新建批次', + days: 10, + }, + expectStatus: 403, + }); + assert.equal(disabledSalesDenied.code, 'TENANT_ADMIN_REQUIRED', 'disabled sales membership should lose tenant admin access'); + + const auditLogs = await request('/api/tenant-admin/audit-logs', { + userId: TENANT_ADMIN_USER_ID, + query: { action: 'tenant.member', limit: 20 }, + }); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.upserted'), 'audit logs should include member upsert'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.disabled'), 'audit logs should include member disable'); + + const partnerAuditDenied = await request('/api/tenant-admin/audit-logs', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerAuditDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant audit logs must be tenant isolated'); +} + +async function testReferralAndCrmGrowth() { + const salesMember = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_SALES_USER_ID, + username: 'integration_sales', + phone: '13800000004', + name: 'Integration Sales', + role: 'sales', + status: 'active', + permissions: { + 'codes:*': true, + 'coupons:*': true, + 'referral:*': true, + }, + }, + }); + assert.equal(salesMember.item?.status, 'active', 'tenant admin should reactivate sales membership for referral tests'); + + const agent = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_AGENT_USER_ID, + username: 'integration_agent', + phone: '13800000005', + name: 'Integration Agent', + role: 'agent', + status: 'active', + permissions: { + 'referral:self': true, + }, + }, + }); + assert.equal(agent.item?.role, 'agent', 'tenant admin should create agent membership'); + + const salesCode = await request('/api/referral/invite-code', { + userId: TENANT_SALES_USER_ID, + method: 'POST', + }); + assert.ok(/^[A-Z0-9]{6}$/.test(salesCode.inviteCode), 'sales should get an invite code'); + + const agentCode = await request('/api/referral/invite-code', { + userId: TENANT_AGENT_USER_ID, + method: 'POST', + }); + assert.ok(/^[A-Z0-9]{6}$/.test(agentCode.inviteCode), 'agent should get an invite code'); + + const resolved = await request('/api/referral/resolve', { + userId: false, + method: 'POST', + body: { code: salesCode.inviteCode }, + }); + assert.equal(resolved.valid, true, 'invite code should resolve'); + assert.equal(resolved.inviterId, TENANT_SALES_USER_ID, 'invite code should resolve to sales user'); + + const crmConfig = await request('/api/crm/config', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + enabled: true, + url: 'https://crm.example.test/webhook', + secret: 'crm-secret-smoke', + formName: '集成测试客资', + examType: '专升本', + delaySec: 1, + }, + }); + assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config'); + assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret'); + + const tracked = await request('/api/referral/track-event', { + userId: false, + method: 'POST', + body: { + eventType: 'register', + refCode: salesCode.inviteCode, + targetUserId: USER_ID, + source: 'qrcode', + metadata: { page: 'pages/index/index' }, + }, + }); + assert.equal(tracked.item?.refUserId, TENANT_SALES_USER_ID, 'tracking event should resolve referrer'); + assert.equal(tracked.lead?.bound, true, 'first referral event should bind lead'); + assert.equal(tracked.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'lead should be bound to sales user'); + assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task'); + + const secondBind = await request('/api/referral/bind', { + method: 'POST', + body: { + userId: USER_ID, + refCode: agentCode.inviteCode, + source: 'qrcode', + }, + }); + assert.equal(secondBind.lead?.bound, false, 'second referral bind should be blocked by first-binding protection'); + assert.equal(secondBind.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'protected lead should remain with first sales user'); + + const salesStats = await request('/api/referral/stats', { + userId: TENANT_SALES_USER_ID, + }); + assert.equal(salesStats.item?.leadCount, 1, 'sales should see own lead count'); + + const salesClients = await request('/api/referral/sales-clients', { + userId: TENANT_SALES_USER_ID, + }); + assert.ok(salesClients.items?.some(item => item.studentUserId === USER_ID), 'sales should see own protected client'); + + const agentClients = await request('/api/referral/sales-clients', { + userId: TENANT_AGENT_USER_ID, + }); + assert.ok(!agentClients.items?.some(item => item.studentUserId === USER_ID), 'agent should not see sales protected client'); + + const allStats = await request('/api/referral/sales-stats', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.ok(allStats.items?.some(item => item.referrerUserId === TENANT_SALES_USER_ID && item.leadCount >= 1), 'tenant admin should see all referral stats'); + + const manual = await request('/api/referral/manual-bind', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + studentUserId: USER_ID, + referrerUserId: TENANT_AGENT_USER_ID, + force: true, + source: 'manual', + }, + }); + assert.equal(manual.lead?.bound, true, 'tenant admin should be able to force manual bind'); + assert.equal(manual.lead?.item?.referrerUserId, TENANT_AGENT_USER_ID, 'manual bind should move lead to agent'); + + const qrcode = await request('/api/referral/qrcode', { + userId: TENANT_AGENT_USER_ID, + method: 'POST', + body: { + page: 'pages/index/index', + }, + }); + assert.equal(qrcode.item?.refCode, agentCode.inviteCode, 'qrcode should use agent invite code'); + assert.equal(qrcode.item?.status, 'ready', 'qrcode placeholder should be ready locally'); + + const team = await request('/api/referral/team', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + leaderUserId: TENANT_SALES_USER_ID, + memberUserId: TENANT_AGENT_USER_ID, + relationType: 'agent_network', + }, + }); + assert.equal(team.item?.leaderUserId, TENANT_SALES_USER_ID, 'tenant admin should assign agent leader'); + + const teamList = await request('/api/referral/team', { + userId: TENANT_SALES_USER_ID, + query: { leaderUserId: TENANT_SALES_USER_ID }, + }); + assert.ok(teamList.items?.some(item => item.memberUserId === TENANT_AGENT_USER_ID), 'sales should see own agent team'); + + const crmQueue = await request('/api/crm/queue', { + userId: TENANT_ADMIN_USER_ID, + query: { status: 'pending' }, + }); + assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task'); + assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret'); + + const partnerReferralDenied = await request('/api/referral/sales-stats', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_SALES_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerReferralDenied.code, 'TENANT_ADMIN_REQUIRED', 'sales user must not see another tenant referral stats'); +} + +async function main() { + try { + await startServerIfNeeded(); + console.log(`[INFO] API integration target: ${apiBase}`); + + await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true))); + await check('catalog and learning', testCatalogAndLearning); + await check('profile', testProfile); + await check('scoreline', testScoreline); + await check('question videos', testVideos); + await check('vocabulary', testVocabulary); + await check('commerce', testCommerce); + await check('tenant isolation', testTenantIsolation); + await check('tenant content admin', testTenantContentAdmin); + await check('tenant content assets and imports', testTenantContentAssetsAndImports); + await check('tenant admin operations', testTenantAdminOps); + await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit); + await check('referral and CRM growth', testReferralAndCrmGrowth); + + console.log('API integration tests complete.'); + } finally { + stopServer(); + } +} + +main().catch(error => { + console.error(error); + if (serverLogs) console.error(serverLogs); + process.exitCode = 1; +}); diff --git a/scripts/import-pocketbase/.env.example b/scripts/import-pocketbase/.env.example new file mode 100644 index 00000000..9224881d --- /dev/null +++ b/scripts/import-pocketbase/.env.example @@ -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 diff --git a/scripts/import-pocketbase/package-lock.json b/scripts/import-pocketbase/package-lock.json new file mode 100644 index 00000000..56b4846b --- /dev/null +++ b/scripts/import-pocketbase/package-lock.json @@ -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" + } + } + } +} diff --git a/scripts/import-pocketbase/package.json b/scripts/import-pocketbase/package.json new file mode 100644 index 00000000..d3ffa8a8 --- /dev/null +++ b/scripts/import-pocketbase/package.json @@ -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" + } +} diff --git a/scripts/import-pocketbase/src/analyze-schema.ts b/scripts/import-pocketbase/src/analyze-schema.ts new file mode 100644 index 00000000..4d54d23a --- /dev/null +++ b/scripts/import-pocketbase/src/analyze-schema.ts @@ -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 || '-'}`); +} diff --git a/scripts/import-pocketbase/src/db.ts b/scripts/import-pocketbase/src/db.ts new file mode 100644 index 00000000..a8cf872f --- /dev/null +++ b/scripts/import-pocketbase/src/db.ts @@ -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(sql: string, params: unknown[] = []): Promise { + return runQuery(pool, sql, params); +} + +export async function queryOne(sql: string, params: unknown[] = []): Promise { + return runQueryOne(pool, sql, params); +} + +export async function closeDb() { + await pool.end(); +} diff --git a/scripts/import-pocketbase/src/env.ts b/scripts/import-pocketbase/src/env.ts new file mode 100644 index 00000000..7595faec --- /dev/null +++ b/scripts/import-pocketbase/src/env.ts @@ -0,0 +1,5 @@ +import { loadDotenv } from '../../../packages/config/src/index.js'; + +export function loadEnv() { + loadDotenv(); +} diff --git a/scripts/import-pocketbase/src/import-json.ts b/scripts/import-pocketbase/src/import-json.ts new file mode 100644 index 00000000..acc337ca --- /dev/null +++ b/scripts/import-pocketbase/src/import-json.ts @@ -0,0 +1,2926 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { closeDb, pool, queryOne } from './db.js'; +import { loadEnv } from './env.js'; + +loadEnv(); + +type JsonRecord = Record & { id?: string }; +type CollectionMap = Record; +type Issue = { + severity: 'info' | 'warning' | 'error' | 'critical'; + issueCode: string; + message: string; + fieldPath?: string; + rawValueSample?: string; +}; + +const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; +const tenantSlug = process.env.TENANT_SLUG || 'master'; +const tenantName = process.env.TENANT_NAME || '升本刷题通主租户'; +const exportDir = path.resolve(process.cwd(), process.env.PB_EXPORT_DIR || '../../pb_export'); +const sourceName = process.env.PB_SOURCE_NAME || path.basename(exportDir); +const importSecretValues = process.env.IMPORT_SECRET_VALUES === 'true'; + +const sensitiveKeyPattern = + /(password|token|secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey|openid|unionid|wxaccesstoken|wechatsessionkey|smscode|verifycode|verificationcode|captcha)/i; +const identityKeyPattern = /(phone|email|openid|unionid|sessionkey)/i; + +const legacyLookupTables = new Set([ + 'activation_codes', + 'badges', + 'categories', + 'code_batches', + 'coupons', + 'coupon_redemptions', + 'handbook_chapters', + 'handbook_entries', + 'handbook_subjects', + 'majors', + 'module_nodes', + 'orders', + 'products', + 'questions', + 'region_modules', + 'regions', + 'reports', + 'schools', + 'scoreline_majors', + 'scoreline_schools', + 'subjects', + 'svip_plans', + 'video_explanations', + 'vocabulary_units', + 'vocabulary_words', +]); + +function asArray(input: unknown): JsonRecord[] { + if (Array.isArray(input)) return input as JsonRecord[]; + if (input && typeof input === 'object' && Array.isArray((input as { items?: unknown[] }).items)) { + return (input as { items: JsonRecord[] }).items; + } + if (input && typeof input === 'object' && Array.isArray((input as { records?: unknown[] }).records)) { + return (input as { records: JsonRecord[] }).records; + } + return []; +} + +function collectionNameFromFile(fileName: string) { + return fileName.replace(/\.json$/i, ''); +} + +function redactValue(value: unknown) { + if (value === null || value === undefined || value === '') return value; + return '[REDACTED]'; +} + +function sanitizeValue(value: unknown, keyPath = ''): unknown { + const key = keyPath.split('.').pop() || keyPath; + if (sensitiveKeyPattern.test(key)) return redactValue(value); + if (Array.isArray(value)) return value.map((item, index) => sanitizeValue(item, `${keyPath}.${index}`)); + if (value && typeof value === 'object') { + const copy: Record = {}; + for (const [childKey, childValue] of Object.entries(value as Record)) { + copy[childKey] = sanitizeValue(childValue, keyPath ? `${keyPath}.${childKey}` : childKey); + } + return copy; + } + return value; +} + +function sanitizeRecord(record: JsonRecord): JsonRecord { + return sanitizeValue(record) as JsonRecord; +} + +function stripSensitiveKeys(value: unknown, keyPath = ''): unknown { + const key = keyPath.split('.').pop() || keyPath; + if (sensitiveKeyPattern.test(key)) return undefined; + if (Array.isArray(value)) { + return value.map((item, index) => stripSensitiveKeys(item, `${keyPath}.${index}`)).filter(item => item !== undefined); + } + if (value && typeof value === 'object') { + const copy: Record = {}; + for (const [childKey, childValue] of Object.entries(value as Record)) { + const stripped = stripSensitiveKeys(childValue, keyPath ? `${keyPath}.${childKey}` : childKey); + if (stripped !== undefined) copy[childKey] = stripped; + } + return copy; + } + return value; +} + +function publicProfile(record: JsonRecord): JsonRecord { + return stripSensitiveKeys(record) as JsonRecord; +} + +function learningStats(value: unknown): JsonRecord { + const stats = parseJsonish(value, {}) as JsonRecord; + const { favorites: _favorites, wrongBook: _wrongBook, ...rest } = stats; + return rest; +} + +function text(value: unknown): string | null { + if (value === null || value === undefined) return null; + const result = String(value).trim(); + return result ? result : null; +} + +function intValue(value: unknown, fallback = 0): number { + if (value === null || value === undefined || value === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function numberValue(value: unknown, fallback: number | null = null): number | null { + if (value === null || value === undefined || value === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function cents(value: unknown, fallback = 0): number { + const parsed = numberValue(value, null); + return parsed === null ? fallback : Math.round(parsed * 100); +} + +function boolValue(value: unknown, fallback = false): boolean { + if (value === null || value === undefined || value === '') return fallback; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; + return fallback; +} + +function parseJsonish(value: unknown, fallback: unknown): unknown { + if (value === null || value === undefined || value === '') return fallback; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return fallback; + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return JSON.parse(trimmed); + } catch { + return fallback; + } + } + } + return value; +} + +function json(value: unknown, fallback: unknown) { + return JSON.stringify(parseJsonish(value, fallback)); +} + +function arrayValue(value: unknown): unknown[] { + const parsed = parseJsonish(value, []); + if (Array.isArray(parsed)) return parsed; + if (typeof parsed === 'string' && parsed.trim()) return parsed.split(',').map(item => item.trim()).filter(Boolean); + return []; +} + +function entriesValue(value: unknown): Array<[string, unknown]> { + const parsed = parseJsonish(value, {}); + if (Array.isArray(parsed)) return parsed.map((item, index) => [String(index), item]); + if (parsed && typeof parsed === 'object') return Object.entries(parsed as Record); + return []; +} + +function dateText(value: unknown): string { + return text(value) || ''; +} + +function normalizeTenantRole(roleValue: unknown): string { + const role = text(roleValue)?.toLowerCase(); + if (role === 'superadmin') return 'platform_admin'; + if (role === 'admin') return 'tenant_admin'; + if (role === 'operator') return 'tenant_operator'; + if (role === 'teacher') return 'teacher'; + if (role === 'sales') return 'sales'; + if (role === 'agent') return 'agent'; + return 'student'; +} + +function normalizeOrderStatus(value: unknown): string { + const status = text(value)?.toLowerCase(); + if (status === 'paid' || status === 'success') return 'paid'; + if (status === 'failed' || status === 'fail') return 'failed'; + if (status === 'refunded' || status === 'refund') return 'refunded'; + if (status === 'closed' || status === 'cancelled' || status === 'canceled') return 'closed'; + return 'pending'; +} + +function normalizePaymentStatus(value: unknown): string { + const status = normalizeOrderStatus(value); + if (status === 'closed') return 'cancelled'; + return status; +} + +function normalizeCouponType(value: unknown): string | null { + const type = text(value)?.toLowerCase(); + if (!type) return null; + if (['percent', 'percentage', 'rate'].includes(type)) return 'percent'; + return 'fixed'; +} + +function walkIssues(collection: string, value: unknown, pathParts: string[] = []): Issue[] { + const issues: Issue[] = []; + if (!value || typeof value !== 'object') return issues; + + for (const [key, childValue] of Object.entries(value as Record)) { + const fieldPath = [...pathParts, key].join('.'); + if (sensitiveKeyPattern.test(key) && childValue !== null && childValue !== undefined && childValue !== '') { + issues.push({ + severity: collection === 'settings' || collection === 'crm_config' ? 'critical' : 'warning', + issueCode: 'sensitive_field_in_source', + message: `Sensitive source field "${fieldPath}" must not be copied into public business tables.`, + fieldPath, + rawValueSample: String(childValue).slice(0, 6) + '...', + }); + } + if (childValue && typeof childValue === 'object') issues.push(...walkIssues(collection, childValue, [...pathParts, key])); + } + + return issues; +} + +function detectIssues(collection: string, record: JsonRecord): Issue[] { + const issues = walkIssues(collection, record); + + if (collection === 'settings') { + issues.push({ + severity: 'warning', + issueCode: 'settings_monolith', + message: 'Legacy settings is a monolithic config table. Values must be split into public config, private secrets, payment accounts and storage config.', + }); + } + + if (collection === 'users') { + if (record.stats && typeof record.stats === 'object') { + issues.push({ + severity: 'info', + issueCode: 'json_stats_requires_normalization', + message: 'users.stats is legacy JSON and should be normalized into learning/favorite/wrong-book tables.', + fieldPath: 'stats', + }); + } + if (record.isSvip !== undefined || record.svipRegions !== undefined || record.svipExpiry !== undefined) { + issues.push({ + severity: 'warning', + issueCode: 'legacy_membership_state', + message: 'Legacy SVIP fields should be converted into entitlements instead of copied as mutable user flags.', + }); + } + } + + if (['smscodes', 'customer_messages'].includes(collection)) { + issues.push({ + severity: 'warning', + issueCode: 'privacy_sensitive_collection', + message: `${collection} contains personal or verification data and is intentionally not imported into public business tables by default.`, + }); + } + + return issues; +} + +async function ensureTenant() { + await pool.query( + ` + insert into public.tenants (id, slug, name, status, mode) + values ($1, $2, $3, 'active', 'platform_owned') + on conflict (id) do update set + slug = excluded.slug, + name = excluded.name, + updated_at = now() + `, + [tenantId, tenantSlug, tenantName], + ); + + await pool.query( + ` + insert into public.tenant_branding (tenant_id, brand_name, short_name) + values ($1, $2, $3) + on conflict (tenant_id) do nothing + `, + [tenantId, tenantName, tenantName], + ); + + await pool.query( + ` + insert into public.tenant_settings (tenant_id) + values ($1) + on conflict (tenant_id) do nothing + `, + [tenantId], + ); +} + +async function createRun() { + const row = await queryOne<{ id: string }>( + ` + insert into public.pb_import_runs (tenant_id, source_name, source_kind) + values ($1, $2, 'json') + returning id + `, + [tenantId, sourceName], + ); + if (!row) throw new Error('Failed to create import run'); + return row.id; +} + +async function importRaw(runId: string, collection: string, records: JsonRecord[]) { + let count = 0; + for (const record of records) { + const legacyId = text(record.id); + if (!legacyId) continue; + const sanitized = sanitizeRecord(record); + await pool.query( + ` + insert into public.pb_raw_records (run_id, tenant_id, collection_name, legacy_id, record) + values ($1, $2, $3, $4, $5) + on conflict (run_id, collection_name, legacy_id) + do update set record = excluded.record, imported_at = now() + `, + [runId, tenantId, collection, legacyId, JSON.stringify(sanitized)], + ); + + for (const issue of detectIssues(collection, record)) { + await pool.query( + ` + insert into public.pb_import_issues ( + run_id, tenant_id, collection_name, legacy_id, severity, + issue_code, message, field_path, raw_value_sample + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9) + `, + [ + runId, + tenantId, + collection, + legacyId, + issue.severity, + issue.issueCode, + issue.message, + issue.fieldPath || null, + issue.rawValueSample || null, + ], + ); + } + count += 1; + } + return count; +} + +async function issue(runId: string, collection: string, legacyId: unknown, issueCode: string, message: string, severity: Issue['severity'] = 'warning') { + await pool.query( + ` + insert into public.pb_import_issues ( + run_id, tenant_id, collection_name, legacy_id, severity, issue_code, message + ) + values ($1,$2,$3,$4,$5,$6,$7) + `, + [runId, tenantId, collection, text(legacyId), severity, issueCode, message], + ); +} + +async function markNormalized(runId: string, collection: string) { + await pool.query( + ` + update public.pb_raw_records + set normalized = true + where run_id = $1 and collection_name = $2 + `, + [runId, collection], + ); +} + +async function legacyId(tableName: string, legacyValue: unknown): Promise { + const value = text(legacyValue); + if (!value) return null; + if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`); + const row = await queryOne<{ id: string }>( + `select id from public.${tableName} where tenant_id = $1 and legacy_id = $2 limit 1`, + [tenantId, value], + ); + return row?.id || null; +} + +async function userIdByLegacy(value: unknown): Promise { + const legacyValue = text(value); + if (!legacyValue) return null; + const row = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1 limit 1', [legacyValue]); + return row?.id || null; +} + +async function upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) { + if (!importSecretValues || secretValue === null || secretValue === undefined || secretValue === '') return; + await pool.query( + ` + insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider) + values ($1,$2,$3,$4,$5) + on conflict (tenant_id, secret_scope, secret_key) + do update set secret_value = excluded.secret_value, provider = excluded.provider, updated_at = now() + `, + [tenantId, secretScope, secretKey, String(secretValue), provider], + ); +} + +async function normalizeRegions(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.regions ( + tenant_id, legacy_id, name, code, short_name, full_name, icon, pinyin, + sort_order, is_hot, is_active, config, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, + coalesce(nullif($13::text,'')::timestamptz, now()), + coalesce(nullif($14::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + name = excluded.name, + code = excluded.code, + short_name = excluded.short_name, + full_name = excluded.full_name, + icon = excluded.icon, + pinyin = excluded.pinyin, + sort_order = excluded.sort_order, + is_hot = excluded.is_hot, + is_active = excluded.is_active, + config = excluded.config, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.name) || '未命名地区', + text(r.code), + text(r.shortName), + text(r.fullName), + text(r.icon), + text(r.pinyin), + intValue(r.order), + boolValue(r.isHot), + boolValue(r.isActive, true), + json(r.config, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeRegionModules(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.region_modules ( + tenant_id, region_id, legacy_id, name, type, icon, color, text_color, + description, route, sort_order, is_primary_school_module, is_active, + created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, + coalesce(nullif($14::text,'')::timestamptz, now()), + coalesce(nullif($15::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + name = excluded.name, + type = excluded.type, + icon = excluded.icon, + color = excluded.color, + text_color = excluded.text_color, + description = excluded.description, + route = excluded.route, + sort_order = excluded.sort_order, + is_primary_school_module = excluded.is_primary_school_module, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.name) || '未命名模块', + text(r.type), + text(r.icon), + text(r.color), + text(r.textColor), + text(r.description), + text(r.route), + intValue(r.order), + boolValue(r.isPrimarySchoolModule), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeModuleNodes(records: JsonRecord[]) { + for (const r of records) { + const legacyParent = text(r.parentId); + await pool.query( + ` + insert into public.module_nodes ( + tenant_id, region_id, module_id, parent_id, legacy_id, legacy_parent_id, + legacy_module_id, type, name, sort_order, is_active, metadata, + created_at, updated_at + ) + values ($1,$2,$3,null,$4,$5,$6,$7,$8,$9,$10,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + module_id = excluded.module_id, + legacy_parent_id = excluded.legacy_parent_id, + legacy_module_id = excluded.legacy_module_id, + type = excluded.type, + name = excluded.name, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('region_modules', r.moduleId), + r.id, + legacyParent, + text(r.moduleId), + ['category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom'].includes(text(r.type) || '') + ? text(r.type) + : 'custom', + text(r.name) || '未命名节点', + intValue(r.order), + boolValue(r.isActive, true), + json(r.metadata, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } + + await pool.query( + ` + update public.module_nodes child + set parent_id = parent.id + from public.module_nodes parent + where child.tenant_id = $1 + and parent.tenant_id = child.tenant_id + and child.legacy_parent_id is not null + and parent.legacy_id = child.legacy_parent_id + `, + [tenantId], + ); +} + +async function normalizeSchools(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.schools ( + tenant_id, region_id, module_id, legacy_id, name, professional_exam_date, + metadata, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + module_id = excluded.module_id, + name = excluded.name, + professional_exam_date = excluded.professional_exam_date, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('region_modules', r.moduleId), + r.id, + text(r.name) || '未命名院校', + text(r.professionalExamDate), + json({ isActive: boolValue(r.isActive, true) }, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeMajors(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.majors ( + tenant_id, region_id, school_id, legacy_id, name, description, + study_tips, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9, + coalesce(nullif($10::text,'')::timestamptz, now()), + coalesce(nullif($11::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + school_id = excluded.school_id, + name = excluded.name, + description = excluded.description, + study_tips = excluded.study_tips, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('schools', r.schoolId), + r.id, + text(r.name) || '未命名专业', + text(r.description), + text(r.studyTips), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeSubjects(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.subjects ( + tenant_id, region_id, module_id, school_id, major_id, node_id, + legacy_id, name, type, major_legacy_ids, icon, description, + stats, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,null,$6,$7,$8,$9,$10,$11,$12,$13,true, + coalesce(nullif($14::text,'')::timestamptz, now()), + coalesce(nullif($15::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + module_id = excluded.module_id, + school_id = excluded.school_id, + major_id = excluded.major_id, + name = excluded.name, + type = excluded.type, + major_legacy_ids = excluded.major_legacy_ids, + icon = excluded.icon, + description = excluded.description, + stats = excluded.stats, + sort_order = excluded.sort_order, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('region_modules', r.moduleId), + await legacyId('schools', r.schoolId), + await legacyId('majors', r.majorId), + r.id, + text(r.name) || '未命名科目', + text(r.type) === 'professional' ? 'professional' : text(r.type) === 'cultural' ? 'cultural' : null, + json(arrayValue(r.majorIds), []), + text(r.icon), + text(r.description), + json(r.stats, {}), + intValue(r.order), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeCategories(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.categories ( + tenant_id, subject_id, node_id, legacy_id, name, category_type, + sort_order, svip_question_limit, is_active, created_at, updated_at + ) + values ($1,$2,null,$3,$4,$5,$6,$7,true, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + subject_id = excluded.subject_id, + name = excluded.name, + category_type = excluded.category_type, + sort_order = excluded.sort_order, + svip_question_limit = excluded.svip_question_limit, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('subjects', r.subjectId), + r.id, + text(r.name) || '未命名章节', + text(r.categoryType) === 'paper' ? 'paper' : 'chapter', + intValue(r.order), + numberValue(r.svipQuestionLimit, null), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeQuestions(records: JsonRecord[]) { + for (const r of records) { + const inserted = await queryOne<{ id: string }>( + ` + insert into public.questions ( + tenant_id, subject_id, category_id, node_id, legacy_id, legacy_subject_id, + legacy_category_id, legacy_node_id, type, type_label, difficulty, tags, + media_url, status, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'published', + coalesce(nullif($14::text,'')::timestamptz, now()), + coalesce(nullif($15::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + subject_id = excluded.subject_id, + category_id = excluded.category_id, + node_id = excluded.node_id, + legacy_subject_id = excluded.legacy_subject_id, + legacy_category_id = excluded.legacy_category_id, + legacy_node_id = excluded.legacy_node_id, + type = excluded.type, + type_label = excluded.type_label, + difficulty = excluded.difficulty, + tags = excluded.tags, + media_url = excluded.media_url, + updated_at = excluded.updated_at + returning id + `, + [ + tenantId, + await legacyId('subjects', r.subjectId), + await legacyId('categories', r.categoryId), + await legacyId('module_nodes', r.nodeId), + r.id, + text(r.subjectId), + text(r.categoryId), + text(r.nodeId), + text(r.type) || 'choice', + text(r.typeLabel), + numberValue(r.difficulty, null), + json(r.tags, []), + text(r.media), + dateText(r.created), + dateText(r.updated), + ], + ); + + if (!inserted) continue; + + const version = await queryOne<{ 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 + ) + values ($1,$2,1,$3,$4,$5,$6,$7,$8,$9,$10,$11) + 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, + sub_questions = excluded.sub_questions, + code_lang = excluded.code_lang, + code_template = excluded.code_template + returning id + `, + [ + tenantId, + inserted.id, + text(r.content), + json(r.options, []), + numberValue(r.correctOptionIndex, null), + json(r.correctOptionIndices, []), + text(r.answerText), + text(r.explanation), + json(r.sub_questions || r.subQuestions, []), + text(r.code_lang), + text(r.code_template), + ], + ); + + if (version) { + await pool.query('update public.questions set current_version_id = $1 where id = $2', [version.id, inserted.id]); + } + } +} + +async function normalizeUsers(records: JsonRecord[]) { + for (const r of records) { + const safeProfile = publicProfile(r); + await pool.query( + ` + insert into public.platform_users ( + legacy_id, username, email, phone, name, avatar_url, primary_role, score, + last_seen_at, password_migration_required, raw_profile, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10, + coalesce(nullif($11::text,'')::timestamptz, now()), + coalesce(nullif($12::text,'')::timestamptz, now()) + ) + on conflict (legacy_id) do update set + username = excluded.username, + email = excluded.email, + phone = excluded.phone, + name = excluded.name, + avatar_url = excluded.avatar_url, + primary_role = excluded.primary_role, + score = excluded.score, + raw_profile = excluded.raw_profile, + updated_at = excluded.updated_at + `, + [ + r.id, + text(r.username), + text(r.email), + text(r.phone), + text(r.name) || text(r.username), + text(r.avatar), + text(r.role) || 'student', + intValue(r.score), + dateText(r.lastSeenAt), + JSON.stringify(safeProfile), + dateText(r.created), + dateText(r.updated), + ], + ); + + const user = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1', [r.id]); + if (!user) continue; + + await pool.query( + ` + insert into public.tenant_memberships (tenant_id, user_id, role, legacy_role) + values ($1, $2, $3, $4) + on conflict (tenant_id, user_id, role) do update set legacy_role = excluded.legacy_role + `, + [tenantId, user.id, normalizeTenantRole(r.role), text(r.role) || 'student'], + ); + + await pool.query( + ` + insert into public.student_profiles ( + tenant_id, user_id, legacy_user_id, region_id, selected_school_id, + selected_major_id, questions_answered_today, mastered_words_count, + last_check_in_date, stats, progress, module_selections, + recent_activities, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::date,$10,$11,$12,$13, + coalesce(nullif($14::text,'')::timestamptz, now()), + coalesce(nullif($15::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, user_id) do update set + region_id = excluded.region_id, + selected_school_id = excluded.selected_school_id, + selected_major_id = excluded.selected_major_id, + questions_answered_today = excluded.questions_answered_today, + mastered_words_count = excluded.mastered_words_count, + last_check_in_date = excluded.last_check_in_date, + stats = excluded.stats, + progress = excluded.progress, + module_selections = excluded.module_selections, + recent_activities = excluded.recent_activities, + updated_at = excluded.updated_at + `, + [ + tenantId, + user.id, + r.id, + await legacyId('regions', r.regionId), + await legacyId('schools', r.selectedSchoolId), + await legacyId('majors', r.selectedMajorId), + intValue(r.questionsAnswered), + intValue(r.masteredWordsCount), + dateText(r.lastCheckInDate), + JSON.stringify(learningStats(r.stats)), + json(r.progress, {}), + json(r.moduleSelections, {}), + json(r.recentActivities, []), + dateText(r.created), + dateText(r.updated), + ], + ); + + for (const [key, provider] of [ + ['email', 'email'], + ['phone', 'phone'], + ['wechatUnionId', 'wechat_unionid'], + ['wechatOpenId', 'wechat_openid'], + ['qqOpenId', 'qq_openid'], + ] as const) { + const subject = text(r[key]); + if (!subject) continue; + await pool.query( + ` + insert into public.user_identities (user_id, provider, provider_subject, union_id, open_id, phone, email) + values ($1,$2,$3,$4,$5,$6,$7) + on conflict (provider, provider_subject) do update set user_id = excluded.user_id + `, + [ + user.id, + provider, + subject, + key === 'wechatUnionId' ? subject : null, + key === 'wechatOpenId' || key === 'qqOpenId' ? subject : null, + key === 'phone' ? subject : null, + key === 'email' ? subject : null, + ], + ); + } + } +} + +async function normalizeUserEntitlementsAndStats(runId: string, records: JsonRecord[]) { + for (const r of records) { + const userId = await userIdByLegacy(r.id); + if (!userId) continue; + + await pool.query( + ` + delete from public.entitlements + where tenant_id = $1 and user_id = $2 and source_type = 'migration' and legacy_source_id = $3 + `, + [tenantId, userId, text(r.id)], + ); + + if (boolValue(r.isSvip) || text(r.svipExpiry) || r.svipRegions) { + const svipRegionEntries = entriesValue(r.svipRegions); + const scopes = + svipRegionEntries.length > 0 + ? svipRegionEntries.map(([regionLegacyId, expiresAt]) => ({ regionLegacyId, expiresAt })) + : [{ regionLegacyId: r.svipRegionId || r.regionId || null, expiresAt: r.svipExpiry }]; + + for (const scope of scopes) { + const scopeLegacyId = scope.regionLegacyId; + const regionId = await legacyId('regions', scopeLegacyId); + await pool.query( + ` + insert into public.entitlements ( + tenant_id, user_id, entitlement_type, scope_type, scope_id, source_type, + legacy_source_id, starts_at, expires_at, status, metadata + ) + values ($1,$2,'svip',$3,$4,'migration',$5, + coalesce(nullif($6::text,'')::timestamptz, now()), + nullif($7::text,'')::timestamptz, + 'active', + $8 + ) + `, + [ + tenantId, + userId, + regionId ? 'region' : 'tenant', + regionId, + text(r.id), + dateText(r.created), + dateText(scope.expiresAt || r.svipExpiry), + json({ legacyRegion: scopeLegacyId || null, source: 'users.svip' }, {}), + ], + ); + } + } + + const stats = parseJsonish(r.stats, {}) as JsonRecord; + for (const legacyQuestionId of arrayValue(stats.favorites)) { + const questionId = await legacyId('questions', legacyQuestionId); + if (!questionId) { + await issue(runId, 'users', r.id, 'favorite_question_not_found', `Favorite question not found: ${text(legacyQuestionId)}`); + continue; + } + await pool.query( + ` + insert into public.favorite_questions (tenant_id, user_id, question_id, source) + values ($1,$2,$3,'migration') + on conflict (tenant_id, user_id, question_id) do nothing + `, + [tenantId, userId, questionId], + ); + } + + for (const legacyQuestionId of arrayValue(stats.wrongBook)) { + const questionId = await legacyId('questions', legacyQuestionId); + if (!questionId) { + await issue(runId, 'users', r.id, 'wrong_question_not_found', `Wrong-book question not found: ${text(legacyQuestionId)}`); + continue; + } + await pool.query( + ` + insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count) + values ($1,$2,$3,1) + on conflict (tenant_id, user_id, question_id) + do update set wrong_count = greatest(public.wrong_questions.wrong_count, excluded.wrong_count) + `, + [tenantId, userId, questionId], + ); + } + } +} + +async function normalizeSettings(records: JsonRecord[]) { + for (const r of records) { + const publicConfig: Record = {}; + + for (const [key, value] of Object.entries(r)) { + if (['id', 'created', 'updated'].includes(key)) continue; + if (sensitiveKeyPattern.test(key)) continue; + if (identityKeyPattern.test(key)) continue; + publicConfig[key] = value; + } + + await pool.query( + ` + insert into public.tenant_settings (tenant_id, public_config) + values ($1, $2) + on conflict (tenant_id) do update set + public_config = public.tenant_settings.public_config || excluded.public_config, + updated_at = now() + `, + [tenantId, JSON.stringify(publicConfig)], + ); + + await upsertPaymentAccount('xunhu_alipay', { + appId: text(r.xunhuAlipayAppId), + notifyDomain: text(r.xunhuNotifyDomain), + returnUrl: text(r.xunhuReturnUrl), + }); + await upsertPaymentAccount('xunhu_wechat', { + appId: text(r.xunhuWechatAppId), + notifyDomain: text(r.xunhuNotifyDomain), + returnUrl: text(r.xunhuReturnUrl), + }); + await upsertPaymentAccount('wechat_pay', { + appId: text(r.wxMiniAppId), + mchId: text(r.wxMchId), + notifyUrl: text(r.wxNotifyUrl), + serialNo: text(r.wxSerialNo), + }); + await upsertPaymentAccount('xpay', { + offerId: text(r.xpayOfferId), + env: text(r.xpayEnv), + enabled: boolValue(r.xpayEnabled), + }); + + for (const [key, value] of Object.entries(r)) { + if (!sensitiveKeyPattern.test(key) || value === null || value === undefined || value === '') continue; + const lower = key.toLowerCase(); + const scope = lower.includes('sms') + ? 'sms' + : lower.includes('pay') || lower.includes('xpay') || lower.includes('mchid') || lower.includes('serial') + ? 'payment' + : lower.includes('s3') + ? 'storage' + : lower.includes('wechat') || lower.includes('qq') || lower.includes('wx') + ? 'oauth' + : 'system'; + await upsertSecret(scope, key, value); + } + } +} + +async function upsertPaymentAccount(provider: string, publicConfig: Record) { + const hasAnyValue = Object.values(publicConfig).some(value => value !== null && value !== undefined && value !== ''); + if (!hasAnyValue) return; + await pool.query( + ` + insert into public.tenant_payment_accounts (tenant_id, provider, mode, display_name, status, config_public) + values ($1,$2,'tenant_collect',$3,'pending',$4) + on conflict (tenant_id, provider) do update set + config_public = public.tenant_payment_accounts.config_public || excluded.config_public, + updated_at = now() + `, + [tenantId, provider, provider, JSON.stringify(publicConfig)], + ); +} + +async function normalizeSvipPlans(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.svip_plans ( + tenant_id, region_id, legacy_id, name, price_cents, original_price_cents, + days, description, per_day_label, badge, recommended, coupon_only, + vp_product_id, vp_enabled, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true, + coalesce(nullif($16::text,'')::timestamptz, now()), + coalesce(nullif($17::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + name = excluded.name, + price_cents = excluded.price_cents, + original_price_cents = excluded.original_price_cents, + days = excluded.days, + description = excluded.description, + per_day_label = excluded.per_day_label, + badge = excluded.badge, + recommended = excluded.recommended, + coupon_only = excluded.coupon_only, + vp_product_id = excluded.vp_product_id, + vp_enabled = excluded.vp_enabled, + sort_order = excluded.sort_order, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.name) || '未命名套餐', + cents(r.price), + r.originalPrice === undefined ? null : cents(r.originalPrice), + intValue(r.days), + text(r.desc), + text(r.perDay), + text(r.badge), + boolValue(r.recommended), + boolValue(r.couponOnly), + text(r.vpProductId), + boolValue(r.vpEnabled), + intValue(r.order), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeCodeBatches(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.code_batches ( + tenant_id, legacy_id, name, sale_type, channel, campaign_name, + default_unit_price_cents, cost_price_cents, total_count, days, + region_id, legacy_region_id, issued_at, created_by, remark, + commission_rate, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz,$14,$15,$16, + coalesce(nullif($17::text,'')::timestamptz, now()), + coalesce(nullif($18::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + name = excluded.name, + sale_type = excluded.sale_type, + channel = excluded.channel, + campaign_name = excluded.campaign_name, + default_unit_price_cents = excluded.default_unit_price_cents, + cost_price_cents = excluded.cost_price_cents, + total_count = excluded.total_count, + days = excluded.days, + region_id = excluded.region_id, + legacy_region_id = excluded.legacy_region_id, + issued_at = excluded.issued_at, + created_by = excluded.created_by, + remark = excluded.remark, + commission_rate = excluded.commission_rate, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.name) || '未命名批次', + text(r.saleType), + text(r.channel), + text(r.campaignName), + cents(r.defaultUnitPrice), + cents(r.costPrice), + intValue(r.totalCount), + numberValue(r.days, null), + await legacyId('regions', r.regionId), + text(r.regionId), + dateText(r.issuedAt), + await userIdByLegacy(r.createdBy), + text(r.remark), + numberValue(r.commissionRate, null), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeActivationCodes(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.activation_codes ( + tenant_id, legacy_id, code, days, is_used, used_by, used_at, agent_user_id, + batch_id, sale_type, unit_price_cents, sold_to, used_region_id, + coupon_code, coupon_redemption_id, remark, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,$11,$12,$13,$14,$15,$16, + coalesce(nullif($17::text,'')::timestamptz, now()), + coalesce(nullif($18::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + code = excluded.code, + days = excluded.days, + is_used = excluded.is_used, + used_by = excluded.used_by, + used_at = excluded.used_at, + agent_user_id = excluded.agent_user_id, + batch_id = excluded.batch_id, + sale_type = excluded.sale_type, + unit_price_cents = excluded.unit_price_cents, + sold_to = excluded.sold_to, + used_region_id = excluded.used_region_id, + coupon_code = excluded.coupon_code, + coupon_redemption_id = excluded.coupon_redemption_id, + remark = excluded.remark, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.code) || `legacy-${text(r.id)}`, + intValue(r.days), + boolValue(r.isUsed), + await userIdByLegacy(r.usedBy), + dateText(r.usedAt), + await userIdByLegacy(r.agentId), + await legacyId('code_batches', r.batchId), + text(r.saleType), + r.unitPrice === undefined ? null : cents(r.unitPrice), + text(r.soldTo), + await legacyId('regions', r.usedRegionId), + text(r.couponCode), + await legacyId('coupon_redemptions', r.couponRedemptionId), + text(r.remark), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeCoupons(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.coupons ( + tenant_id, legacy_id, code, plan_id, discount_type, discount_value, + valid_from, valid_to, max_uses, used_count, source, remark, + created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,nullif($8::text,'')::timestamptz,$9,$10,$11,$12, + coalesce(nullif($13::text,'')::timestamptz, now()), + coalesce(nullif($14::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + code = excluded.code, + plan_id = excluded.plan_id, + discount_type = excluded.discount_type, + discount_value = excluded.discount_value, + valid_from = excluded.valid_from, + valid_to = excluded.valid_to, + max_uses = excluded.max_uses, + used_count = excluded.used_count, + source = excluded.source, + remark = excluded.remark, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.code) || `legacy-${text(r.id)}`, + await legacyId('svip_plans', r.planId), + normalizeCouponType(r.discountType), + numberValue(r.discountValue, null), + dateText(r.validFrom), + dateText(r.validTo), + numberValue(r.maxUses, null), + intValue(r.usedCount), + text(r.source), + text(r.remark), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeCouponRedemptions(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.coupon_redemptions ( + tenant_id, legacy_id, coupon_id, coupon_code, user_id, plan_id, order_id, + status, discount_applied_cents, region_id, source, remark, claimed_at, + used_at, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, + nullif($13::text,'')::timestamptz, + nullif($14::text,'')::timestamptz, + coalesce(nullif($15::text,'')::timestamptz, now()), + coalesce(nullif($16::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + coupon_id = excluded.coupon_id, + coupon_code = excluded.coupon_code, + user_id = excluded.user_id, + plan_id = excluded.plan_id, + order_id = excluded.order_id, + status = excluded.status, + discount_applied_cents = excluded.discount_applied_cents, + region_id = excluded.region_id, + source = excluded.source, + remark = excluded.remark, + claimed_at = excluded.claimed_at, + used_at = excluded.used_at, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + await legacyId('coupons', r.couponId), + text(r.couponCode), + await userIdByLegacy(r.userId), + await legacyId('svip_plans', r.planId), + await legacyId('orders', r.orderId), + text(r.status) || 'claimed', + r.discountApplied === undefined ? null : cents(r.discountApplied), + await legacyId('regions', r.regionId), + text(r.source), + text(r.remark), + dateText(r.claimedAt), + dateText(r.usedAt), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeOrders(records: JsonRecord[]) { + for (const r of records) { + const order = await queryOne<{ id: string }>( + ` + insert into public.orders ( + tenant_id, user_id, legacy_id, legacy_user_id, order_no, status, + product_type, product_name, amount_cents, pay_method, pay_provider, + trade_no, plan_id, legacy_plan_id, days, region_id, legacy_region_id, + paid_at, raw_payload, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,'svip',$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, + nullif($17::text,'')::timestamptz,$18, + coalesce(nullif($19::text,'')::timestamptz, now()), + coalesce(nullif($20::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + user_id = excluded.user_id, + legacy_user_id = excluded.legacy_user_id, + order_no = excluded.order_no, + status = excluded.status, + product_name = excluded.product_name, + amount_cents = excluded.amount_cents, + pay_method = excluded.pay_method, + pay_provider = excluded.pay_provider, + trade_no = excluded.trade_no, + plan_id = excluded.plan_id, + legacy_plan_id = excluded.legacy_plan_id, + days = excluded.days, + region_id = excluded.region_id, + legacy_region_id = excluded.legacy_region_id, + paid_at = excluded.paid_at, + raw_payload = excluded.raw_payload, + updated_at = excluded.updated_at + returning id + `, + [ + tenantId, + await userIdByLegacy(r.userId), + r.id, + text(r.userId), + text(r.orderNo) || `legacy-${text(r.id)}`, + normalizeOrderStatus(r.status), + text(r.planName), + cents(r.amount), + text(r.payMethod), + text(r.payProvider), + text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId), + await legacyId('svip_plans', r.planId), + text(r.planId), + numberValue(r.days, null), + await legacyId('regions', r.regionId), + text(r.regionId), + dateText(r.paidAt), + JSON.stringify(sanitizeRecord(r)), + dateText(r.created), + dateText(r.updated), + ], + ); + + if (!order) continue; + + await pool.query( + ` + insert into public.order_items ( + tenant_id, order_id, legacy_id, item_type, item_id, name, + quantity, unit_amount_cents, total_amount_cents, metadata + ) + values ($1,$2,$3,'svip_plan',$4,$5,1,$6,$6,$7) + on conflict (tenant_id, legacy_id) do update set + order_id = excluded.order_id, + item_id = excluded.item_id, + name = excluded.name, + unit_amount_cents = excluded.unit_amount_cents, + total_amount_cents = excluded.total_amount_cents, + metadata = excluded.metadata + `, + [ + tenantId, + order.id, + r.id, + await legacyId('svip_plans', r.planId), + text(r.planName) || 'SVIP会员', + cents(r.amount), + json({ legacyPlanId: text(r.planId), days: numberValue(r.days, null) }, {}), + ], + ); + + await pool.query( + ` + insert into public.payments ( + tenant_id, order_id, legacy_id, legacy_order_id, provider, method, status, + amount_cents, provider_trade_no, paid_at, raw_payload, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + provider = excluded.provider, + method = excluded.method, + status = excluded.status, + amount_cents = excluded.amount_cents, + provider_trade_no = excluded.provider_trade_no, + paid_at = excluded.paid_at, + raw_payload = excluded.raw_payload, + updated_at = excluded.updated_at + `, + [ + tenantId, + order.id, + r.id, + text(r.id), + text(r.payProvider) || text(r.payMethod) || 'legacy', + text(r.payMethod), + normalizePaymentStatus(r.status), + cents(r.amount), + text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId), + dateText(r.paidAt), + JSON.stringify(sanitizeRecord(r)), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeVocabularyUnits(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.vocabulary_units ( + tenant_id, region_id, legacy_id, name, description, word_count, + sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.name) || '未命名单元', + text(r.description), + numberValue(r.wordCount, null), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeVocabularyWords(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.vocabulary_words ( + tenant_id, unit_id, legacy_id, word, phonetic, meaning, example, + example_translation, difficulty, tags, sort_order, is_active, + created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, + coalesce(nullif($13::text,'')::timestamptz, now()), + coalesce(nullif($14::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('vocabulary_units', r.unit), + r.id, + text(r.word) || 'unknown', + text(r.phonetic), + text(r.meaning), + text(r.example), + text(r.exampleTranslation), + numberValue(r.difficulty, null), + json(r.tags, []), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeHandbookSubjects(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.handbook_subjects ( + tenant_id, region_id, legacy_id, name, type, icon, color, description, + sort_order, is_active, metadata, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.name) || '未命名手册科目', + text(r.type), + text(r.icon), + text(r.color), + text(r.description), + intValue(r.order), + boolValue(r.isActive, true), + json({ legacySchoolId: text(r.schoolId), legacyMajorId: text(r.majorId), legacyMajorIds: arrayValue(r.majorIds) }, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeHandbookChapters(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.handbook_chapters ( + tenant_id, subject_id, legacy_id, name, description, sort_order, + is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('handbook_subjects', r.subjectId), + r.id, + text(r.name) || '未命名手册章节', + text(r.description), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeHandbookEntries(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.handbook_entries ( + tenant_id, chapter_id, legacy_id, title, summary, content, tags, + sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9, + coalesce(nullif($10::text,'')::timestamptz, now()), + coalesce(nullif($11::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('handbook_chapters', r.chapterId), + r.id, + text(r.title) || '未命名知识点', + text(r.summary), + text(r.content), + json(r.tags, []), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeSimpleContent(records: JsonRecord[], table: 'banners' | 'faqs' | 'announcements') { + for (const r of records) { + if (table === 'banners') { + await pool.query( + ` + insert into public.banners ( + tenant_id, region_id, legacy_id, title, subtitle, content, button_text, + button_link, bg_color, border_color, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, + coalesce(nullif($13::text,'')::timestamptz, now()), + coalesce(nullif($14::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + title = excluded.title, + subtitle = excluded.subtitle, + content = excluded.content, + button_text = excluded.button_text, + button_link = excluded.button_link, + bg_color = excluded.bg_color, + border_color = excluded.border_color, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.title), + text(r.subtitle), + text(r.content), + text(r.buttonText), + text(r.buttonLink), + text(r.bgColor), + text(r.borderColor), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } else if (table === 'faqs') { + await pool.query( + ` + insert into public.faqs ( + tenant_id, region_id, legacy_id, question, answer, sort_order, + is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + question = excluded.question, + answer = excluded.answer, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.question), + text(r.answer), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } else { + await pool.query( + ` + insert into public.announcements ( + tenant_id, legacy_id, content, link, bg_color, sort_order, + is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + content = excluded.content, + link = excluded.link, + bg_color = excluded.bg_color, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.content), + text(r.link), + text(r.bgColor), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } + } +} + +async function normalizeProducts(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.products ( + tenant_id, region_id, legacy_id, title, price_label, link, type, tags, + cover, preview_iframe, detail_images, sort_order, status, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active', + coalesce(nullif($13::text,'')::timestamptz, now()), + coalesce(nullif($14::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + title = excluded.title, + price_label = excluded.price_label, + link = excluded.link, + type = excluded.type, + tags = excluded.tags, + cover = excluded.cover, + preview_iframe = excluded.preview_iframe, + detail_images = excluded.detail_images, + sort_order = excluded.sort_order, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.title) || '未命名商品', + text(r.price), + text(r.link), + text(r.type), + json(r.tags, []), + text(r.cover), + text(r.previewIframe), + json(r.detailImages, []), + intValue(r.order), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeTimelines(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.timelines ( + tenant_id, region_id, school_id, legacy_id, type, title, description, + event_date, link, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::date,$9,$10,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + school_id = excluded.school_id, + type = excluded.type, + title = excluded.title, + description = excluded.description, + event_date = excluded.event_date, + link = excluded.link, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('schools', r.schoolId), + r.id, + text(r.type), + text(r.title) || '未命名时间线', + text(r.description), + dateText(r.eventDate), + text(r.link), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeExamDates(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.exam_dates ( + tenant_id, region_id, school_id, legacy_id, exam_name, exam_date, + exam_type, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,nullif($6::text,'')::date,$7,$8,$9, + coalesce(nullif($10::text,'')::timestamptz, now()), + coalesce(nullif($11::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + school_id = excluded.school_id, + exam_name = excluded.exam_name, + exam_date = excluded.exam_date, + exam_type = excluded.exam_type, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('schools', r.schoolId), + r.id, + text(r.examName) || '考试日期', + dateText(r.examDate), + text(r.examType), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeVideoExplanations(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.video_explanations ( + tenant_id, legacy_id, title, description, video_url, thumbnail_url, + duration_seconds, knowledge_tags, is_general, subject_id, legacy_subject_id, + difficulty, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, + coalesce(nullif($15::text,'')::timestamptz, now()), + coalesce(nullif($16::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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, + legacy_subject_id = excluded.legacy_subject_id, + difficulty = excluded.difficulty, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.title) || '未命名视频解析', + text(r.description), + text(r.videoUrl), + text(r.thumbnailUrl), + numberValue(r.duration, null), + json(r.knowledgeTags, []), + boolValue(r.isGeneral), + await legacyId('subjects', r.subjectId), + text(r.subjectId), + numberValue(r.difficulty, null), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeQuestionVideos(records: JsonRecord[]) { + for (const r of records) { + const questionId = await legacyId('questions', r.questionId); + const videoId = await legacyId('video_explanations', r.videoId); + if (!questionId || !videoId) continue; + await pool.query( + ` + insert into public.question_videos ( + tenant_id, question_id, video_id, legacy_id, legacy_question_id, + legacy_video_id, video_type, sort_order, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + question_id = excluded.question_id, + video_id = excluded.video_id, + video_type = excluded.video_type, + sort_order = excluded.sort_order, + updated_at = excluded.updated_at + `, + [ + tenantId, + questionId, + videoId, + r.id, + text(r.questionId), + text(r.videoId), + text(r.videoType) || 'specific', + intValue(r.order), + dateText(r.created), + dateText(r.updated), + ], + ); + } + await pool.query( + ` + update public.questions q + set has_video_explanation = true + where tenant_id = $1 and exists ( + select 1 from public.question_videos qv + where qv.tenant_id = q.tenant_id and qv.question_id = q.id + ) + `, + [tenantId], + ); +} + +async function normalizeReports(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.reports ( + tenant_id, legacy_id, question_id, user_id, type, description, + status, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + question_id = excluded.question_id, + user_id = excluded.user_id, + type = excluded.type, + description = excluded.description, + status = excluded.status, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + await legacyId('questions', r.questionId), + await userIdByLegacy(r.userId), + text(r.type), + text(r.description), + text(r.status) || 'pending', + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeScorelineSchools(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.scoreline_schools ( + tenant_id, region_id, legacy_id, name, short_name, type, is_hot, + sort_order, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.name) || '未命名分数线院校', + text(r.shortName), + text(r.type), + boolValue(r.isHot), + intValue(r.order), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeScorelineMajors(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.scoreline_majors ( + tenant_id, region_id, school_id, legacy_id, name, sort_order, + has_restriction, restriction_desc, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('scoreline_schools', r.schoolId), + r.id, + text(r.name) || '未命名分数线专业', + intValue(r.order), + boolValue(r.hasRestriction), + text(r.restrictionDesc), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeScorelineFields(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.scoreline_fields ( + 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, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15, + coalesce(nullif($16::text,'')::timestamptz, now()), + coalesce(nullif($17::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + region_id = excluded.region_id, + field_key = excluded.field_key, + 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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + r.id, + text(r.fieldKey) || `field_${text(r.id)}`, + text(r.fieldName) || '未命名字段', + text(r.fieldType), + text(r.unit), + boolValue(r.isFilter), + boolValue(r.isRequired), + boolValue(r.isVisible, true), + boolValue(r.isTrend), + json(r.options, []), + text(r.placeholder), + text(r.description), + intValue(r.sortOrder), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeScorelineRecords(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.scoreline_records ( + tenant_id, region_id, school_id, major_id, legacy_id, year, + school_name, major_name, field_values, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9, + coalesce(nullif($10::text,'')::timestamptz, now()), + coalesce(nullif($11::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_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 = excluded.updated_at + `, + [ + tenantId, + await legacyId('regions', r.regionId), + await legacyId('scoreline_schools', r.schoolId), + await legacyId('scoreline_majors', r.majorId), + r.id, + intValue(r.year), + text(r.schoolName), + text(r.majorName), + json(r.fieldValues, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeReferralTracks(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.referral_tracks ( + tenant_id, legacy_id, event_type, ref_code, ref_user_id, + target_user_id, source, ip_address, user_agent, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9, + coalesce(nullif($10::text,'')::timestamptz, now()), + coalesce(nullif($11::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + event_type = excluded.event_type, + ref_code = excluded.ref_code, + ref_user_id = excluded.ref_user_id, + target_user_id = excluded.target_user_id, + source = excluded.source, + ip_address = excluded.ip_address, + user_agent = excluded.user_agent, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.eventType) || 'unknown', + text(r.refCode), + await userIdByLegacy(r.refUserId), + await userIdByLegacy(r.targetUserId), + text(r.source), + text(r.ip), + text(r.userAgent), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeBadges(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.badges ( + tenant_id, legacy_id, name, description, category, icon_url, level, + unlock_type, condition_field, condition_operator, condition_value, + condition_extra, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, + coalesce(nullif($15::text,'')::timestamptz, now()), + coalesce(nullif($16::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + name = excluded.name, + description = excluded.description, + category = excluded.category, + icon_url = excluded.icon_url, + level = excluded.level, + unlock_type = excluded.unlock_type, + condition_field = excluded.condition_field, + condition_operator = excluded.condition_operator, + condition_value = excluded.condition_value, + condition_extra = excluded.condition_extra, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.name) || '未命名徽章', + text(r.description), + text(r.category), + text(r.icon_url), + numberValue(r.level, null), + text(r.unlock_type), + text(r.condition_field), + text(r.condition_operator), + numberValue(r.condition_value, null), + json(r.condition_extra, {}), + intValue(r.sort_order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeUserBadges(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.user_badges ( + tenant_id, user_id, badge_id, granted_by, legacy_id, note, + granted_at, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz, + coalesce(nullif($8::text,'')::timestamptz, now()), + coalesce(nullif($9::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + user_id = excluded.user_id, + badge_id = excluded.badge_id, + granted_by = excluded.granted_by, + note = excluded.note, + granted_at = excluded.granted_at, + updated_at = excluded.updated_at + `, + [ + tenantId, + await userIdByLegacy(r.user), + await legacyId('badges', r.badge), + await userIdByLegacy(r.granted_by), + r.id, + text(r.note), + dateText(r.granted_at), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeWordUserData(progressRecords: JsonRecord[], favoriteRecords: JsonRecord[]) { + for (const r of progressRecords) { + await pool.query( + ` + insert into public.user_word_progress ( + tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id, + status, correct_count, wrong_count, last_review_date, next_review_date, + created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,nullif($11::text,'')::timestamptz, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + user_id = excluded.user_id, + word_id = excluded.word_id, + 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 = excluded.updated_at + `, + [ + tenantId, + await userIdByLegacy(r.userId), + await legacyId('vocabulary_words', r.wordId), + r.id, + text(r.userId), + text(r.wordId), + ['new', 'learning', 'mastered', 'reviewing'].includes(text(r.status) || '') ? text(r.status) : 'new', + intValue(r.correctCount), + intValue(r.wrongCount), + dateText(r.lastReviewDate), + dateText(r.nextReviewDate), + dateText(r.created), + dateText(r.updated), + ], + ); + } + + for (const r of favoriteRecords) { + await pool.query( + ` + insert into public.user_word_favorites ( + tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id, + note, favorited_at, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::timestamptz, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + user_id = excluded.user_id, + word_id = excluded.word_id, + note = excluded.note, + favorited_at = excluded.favorited_at, + updated_at = excluded.updated_at + `, + [ + tenantId, + await userIdByLegacy(r.userId), + await legacyId('vocabulary_words', r.wordId), + r.id, + text(r.userId), + text(r.wordId), + text(r.note), + dateText(r.createdAt), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeRecentPractices(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.recent_practices ( + tenant_id, user_id, legacy_id, practice_type, target_legacy_id, + target_name, progress, color, last_access_at, last_practice_at, + metadata, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,nullif($10::text,'')::timestamptz,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + user_id = excluded.user_id, + practice_type = excluded.practice_type, + target_legacy_id = excluded.target_legacy_id, + target_name = excluded.target_name, + progress = excluded.progress, + color = excluded.color, + last_access_at = excluded.last_access_at, + last_practice_at = excluded.last_practice_at, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `, + [ + tenantId, + await userIdByLegacy(r.userId), + r.id, + text(r.type), + text(r.targetId), + text(r.targetName), + intValue(r.progress), + text(r.color), + dateText(r.lastAccessTime), + dateText(r.lastPracticeAt), + json({ legacyTargetId: text(r.targetId) }, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeAuxiliary(records: JsonRecord[], collection: string) { + for (const r of records) { + if (collection === 'audit_logs') { + await pool.query( + ` + insert into public.audit_logs ( + tenant_id, actor_user_id, action, target_type, target_id, + details, ip_address, user_agent, created_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,coalesce(nullif($9::text,'')::timestamptz, now())) + `, + [ + tenantId, + await userIdByLegacy(r.userId), + text(r.action) || 'legacy_audit', + text(r.targetType), + text(r.targetId), + json({ detail: text(r.detail), metadata: text(r.metadata) }, {}), + text(r.ip), + text(r.userAgent), + dateText(r.created), + ], + ); + } + + if (collection === 'crm_config') { + await pool.query( + ` + insert into public.crm_config ( + tenant_id, enabled, url, secret_ref, form_name, exam_type, + timeout_sec, delay_sec, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + 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 = excluded.updated_at + `, + [ + tenantId, + boolValue(r.enabled), + text(r.url), + text(r.secret) ? 'app_private.tenant_secrets:crm:legacy_crm_secret' : null, + text(r.formName), + text(r.examType), + numberValue(r.timeoutSec, null), + numberValue(r.delaySec, null), + dateText(r.created), + dateText(r.updated), + ], + ); + await upsertSecret('crm', 'legacy_crm_secret', r.secret, 'legacy_crm'); + } + + if (collection === 'crm_webhook_queue') { + await pool.query( + ` + insert into public.crm_webhook_queue ( + tenant_id, legacy_id, record_id, status, scheduled_at, attempts, + next_attempt_at, last_error, last_http_code, lead_id, sent_at, + created_at, updated_at + ) + values ($1,$2,$3,$4,nullif($5::text,'')::timestamptz,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,nullif($11::text,'')::timestamptz, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + record_id = excluded.record_id, + status = excluded.status, + scheduled_at = excluded.scheduled_at, + attempts = excluded.attempts, + next_attempt_at = excluded.next_attempt_at, + last_error = excluded.last_error, + last_http_code = excluded.last_http_code, + lead_id = excluded.lead_id, + sent_at = excluded.sent_at, + updated_at = excluded.updated_at + `, + [ + tenantId, + r.id, + text(r.recordId), + text(r.status) || 'pending', + dateText(r.scheduledAt), + intValue(r.attempts), + dateText(r.nextAttemptAt), + text(r.lastError), + numberValue(r.lastHttpCode, null), + text(r.leadId), + dateText(r.sentAt), + dateText(r.created), + dateText(r.updated), + ], + ); + } + + if (collection === 'crm_webhook_log') { + await pool.query( + ` + insert into public.crm_webhook_log ( + tenant_id, legacy_id, record_id, http_code, outcome, error_message, + lead_id, request_body, response_summary, signed_at, attempt, created_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11, + coalesce(nullif($12::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + record_id = excluded.record_id, + http_code = excluded.http_code, + outcome = excluded.outcome, + error_message = excluded.error_message, + lead_id = excluded.lead_id, + request_body = excluded.request_body, + response_summary = excluded.response_summary, + signed_at = excluded.signed_at, + attempt = excluded.attempt + `, + [ + tenantId, + r.id, + text(r.recordId), + numberValue(r.httpCode, null), + text(r.outcome), + text(r.errorMessage), + text(r.leadId), + text(r.requestBody), + text(r.responseSummary), + dateText(r.signedAt), + numberValue(r.attempt, null), + dateText(r.created), + ], + ); + } + } +} + +async function normalizeContentAssets(records: JsonRecord[], collection: 'app_assets' | 'images') { + for (const r of records) { + await pool.query( + ` + insert into public.content_assets ( + tenant_id, legacy_id, asset_key, title, category, description, + file_name, cdn_url, is_public, metadata, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, + coalesce(nullif($11::text,'')::timestamptz, now()), + coalesce(nullif($12::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + asset_key = excluded.asset_key, + title = excluded.title, + category = excluded.category, + description = excluded.description, + file_name = excluded.file_name, + cdn_url = excluded.cdn_url, + is_public = excluded.is_public, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `, + [ + tenantId, + `${collection}:${text(r.id)}`, + text(r.key), + text(r.title), + text(r.category), + text(r.desc), + text(r.image), + text(r.cdnUrl), + boolValue(r.isPublic), + json({ sourceCollection: collection }, {}), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeDailyStats(records: JsonRecord[], collection: 'dashboard_daily_stats' | 'revenue_daily_stats') { + for (const r of records) { + if (collection === 'dashboard_daily_stats') { + await pool.query( + ` + insert into public.dashboard_daily_stats ( + tenant_id, legacy_id, stat_date, region_id, legacy_region_id, + new_users, new_questions, new_orders, new_revenue_cents, + active_users, rebuilt_at + ) + values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,nullif($11::text,'')::timestamptz) + on conflict (tenant_id, stat_date, legacy_region_id) do update set + new_users = excluded.new_users, + new_questions = excluded.new_questions, + new_orders = excluded.new_orders, + new_revenue_cents = excluded.new_revenue_cents, + active_users = excluded.active_users, + rebuilt_at = excluded.rebuilt_at + `, + [ + tenantId, + r.id, + dateText(r.statDate), + await legacyId('regions', r.regionId), + text(r.regionId), + intValue(r.newUsers), + intValue(r.newQuestions), + intValue(r.newOrders), + cents(r.newRevenue), + intValue(r.activeUsers), + dateText(r.rebuiltAt), + ], + ); + } else { + await pool.query( + ` + insert into public.revenue_daily_stats ( + tenant_id, legacy_id, stat_date, region_id, legacy_region_id, + sale_type, real_revenue_cents, order_count, code_count, code_used, + code_estimated_cents, estimated_revenue_cents, rebuilt_at + ) + values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz) + on conflict (tenant_id, stat_date, legacy_region_id, sale_type) do update set + real_revenue_cents = excluded.real_revenue_cents, + order_count = excluded.order_count, + code_count = excluded.code_count, + code_used = excluded.code_used, + code_estimated_cents = excluded.code_estimated_cents, + estimated_revenue_cents = excluded.estimated_revenue_cents, + rebuilt_at = excluded.rebuilt_at + `, + [ + tenantId, + r.id, + dateText(r.statDate), + await legacyId('regions', r.regionId), + text(r.regionId), + text(r.saleType) || '', + cents(r.realRevenue), + intValue(r.orderCount), + intValue(r.codeCount), + intValue(r.codeUsed), + cents(r.codeEstimated), + cents(r.estimatedRevenue), + dateText(r.rebuiltAt), + ], + ); + } + } +} + +async function normalizeQuestionTypeGroups(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.question_type_groups ( + tenant_id, subject_id, legacy_id, legacy_subject_id, display_name, + types, sort_order, is_active, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6,$7,$8, + coalesce(nullif($9::text,'')::timestamptz, now()), + coalesce(nullif($10::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + subject_id = excluded.subject_id, + legacy_subject_id = excluded.legacy_subject_id, + display_name = excluded.display_name, + types = excluded.types, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('subjects', r.subjectId), + r.id, + text(r.subjectId), + text(r.displayName) || '题型分组', + json(r.types, []), + intValue(r.order), + boolValue(r.isActive, true), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function normalizeSubjectShares(records: JsonRecord[]) { + for (const r of records) { + await pool.query( + ` + insert into public.subject_shares ( + tenant_id, source_subject_id, target_subject_id, legacy_id, + legacy_source_subject_id, legacy_target_subject_id, created_at, updated_at + ) + values ($1,$2,$3,$4,$5,$6, + coalesce(nullif($7::text,'')::timestamptz, now()), + coalesce(nullif($8::text,'')::timestamptz, now()) + ) + on conflict (tenant_id, legacy_id) do update set + source_subject_id = excluded.source_subject_id, + target_subject_id = excluded.target_subject_id, + legacy_source_subject_id = excluded.legacy_source_subject_id, + legacy_target_subject_id = excluded.legacy_target_subject_id, + updated_at = excluded.updated_at + `, + [ + tenantId, + await legacyId('subjects', r.sourceSubjectId), + await legacyId('subjects', r.targetSubjectId), + r.id, + text(r.sourceSubjectId), + text(r.targetSubjectId), + dateText(r.created), + dateText(r.updated), + ], + ); + } +} + +async function runNormalizer( + runId: string, + collections: CollectionMap, + collection: string, + normalizer: (records: JsonRecord[]) => Promise, +) { + const records = collections[collection] || []; + if (records.length === 0) return; + await normalizer(records); + await markNormalized(runId, collection); + console.log(`${collection}: ${records.length} records normalized`); +} + +async function loadCollections(): Promise { + const files = fs.readdirSync(exportDir).filter(file => file.toLowerCase().endsWith('.json')); + const collections: CollectionMap = {}; + for (const file of files) { + const collection = collectionNameFromFile(file); + collections[collection] = asArray(JSON.parse(fs.readFileSync(path.join(exportDir, file), 'utf8'))); + } + return collections; +} + +async function normalizeAll(runId: string, collections: CollectionMap) { + await runNormalizer(runId, collections, 'regions', normalizeRegions); + await runNormalizer(runId, collections, 'region_modules', normalizeRegionModules); + await runNormalizer(runId, collections, 'module_nodes', normalizeModuleNodes); + await runNormalizer(runId, collections, 'schools', normalizeSchools); + await runNormalizer(runId, collections, 'majors', normalizeMajors); + await runNormalizer(runId, collections, 'subjects', normalizeSubjects); + await runNormalizer(runId, collections, 'categories', normalizeCategories); + await runNormalizer(runId, collections, 'question_type_groups', normalizeQuestionTypeGroups); + await runNormalizer(runId, collections, 'subject_shares', normalizeSubjectShares); + await runNormalizer(runId, collections, 'questions', normalizeQuestions); + + await runNormalizer(runId, collections, 'users', normalizeUsers); + await normalizeUserEntitlementsAndStats(runId, collections.users || []); + if ((collections.users || []).length > 0) await markNormalized(runId, 'users'); + + await runNormalizer(runId, collections, 'settings', normalizeSettings); + await runNormalizer(runId, collections, 'svip_plans', normalizeSvipPlans); + await runNormalizer(runId, collections, 'code_batches', normalizeCodeBatches); + await runNormalizer(runId, collections, 'coupons', normalizeCoupons); + await runNormalizer(runId, collections, 'coupon_redemptions', normalizeCouponRedemptions); + await runNormalizer(runId, collections, 'codes', normalizeActivationCodes); + await runNormalizer(runId, collections, 'orders', normalizeOrders); + + await runNormalizer(runId, collections, 'vocabulary_units', normalizeVocabularyUnits); + await runNormalizer(runId, collections, 'vocabulary', normalizeVocabularyWords); + await normalizeWordUserData(collections.user_word_progress || [], collections.user_word_favorites || []); + if ((collections.user_word_progress || []).length > 0) { + await markNormalized(runId, 'user_word_progress'); + console.log(`user_word_progress: ${collections.user_word_progress.length} records normalized`); + } + if ((collections.user_word_favorites || []).length > 0) { + await markNormalized(runId, 'user_word_favorites'); + console.log(`user_word_favorites: ${collections.user_word_favorites.length} records normalized`); + } + + await runNormalizer(runId, collections, 'handbook_subjects', normalizeHandbookSubjects); + await runNormalizer(runId, collections, 'handbook_chapters', normalizeHandbookChapters); + await runNormalizer(runId, collections, 'handbook_entries', normalizeHandbookEntries); + await runNormalizer(runId, collections, 'banners', records => normalizeSimpleContent(records, 'banners')); + await runNormalizer(runId, collections, 'faqs', records => normalizeSimpleContent(records, 'faqs')); + await runNormalizer(runId, collections, 'announcements', records => normalizeSimpleContent(records, 'announcements')); + await runNormalizer(runId, collections, 'products', normalizeProducts); + await runNormalizer(runId, collections, 'timelines', normalizeTimelines); + await runNormalizer(runId, collections, 'exam_dates', normalizeExamDates); + await runNormalizer(runId, collections, 'reports', normalizeReports); + await runNormalizer(runId, collections, 'video_explanations', normalizeVideoExplanations); + await runNormalizer(runId, collections, 'question_videos', normalizeQuestionVideos); + + await runNormalizer(runId, collections, 'scoreline_schools', normalizeScorelineSchools); + await runNormalizer(runId, collections, 'scoreline_majors', normalizeScorelineMajors); + await runNormalizer(runId, collections, 'scoreline_fields', normalizeScorelineFields); + await runNormalizer(runId, collections, 'scoreline_records', normalizeScorelineRecords); + await runNormalizer(runId, collections, 'referral_tracks', normalizeReferralTracks); + await runNormalizer(runId, collections, 'badges', normalizeBadges); + await runNormalizer(runId, collections, 'user_badges', normalizeUserBadges); + await runNormalizer(runId, collections, 'recent_practices', normalizeRecentPractices); + await runNormalizer(runId, collections, 'audit_logs', records => normalizeAuxiliary(records, 'audit_logs')); + await runNormalizer(runId, collections, 'crm_config', records => normalizeAuxiliary(records, 'crm_config')); + await runNormalizer(runId, collections, 'crm_webhook_queue', records => normalizeAuxiliary(records, 'crm_webhook_queue')); + await runNormalizer(runId, collections, 'crm_webhook_log', records => normalizeAuxiliary(records, 'crm_webhook_log')); + await runNormalizer(runId, collections, 'app_assets', records => normalizeContentAssets(records, 'app_assets')); + await runNormalizer(runId, collections, 'images', records => normalizeContentAssets(records, 'images')); + await runNormalizer(runId, collections, 'dashboard_daily_stats', records => normalizeDailyStats(records, 'dashboard_daily_stats')); + await runNormalizer(runId, collections, 'revenue_daily_stats', records => normalizeDailyStats(records, 'revenue_daily_stats')); +} + +async function main() { + if (!fs.existsSync(exportDir)) { + throw new Error(`Export dir not found: ${exportDir}. Put collection JSON files there, e.g. users.json and questions.json.`); + } + + await ensureTenant(); + const runId = await createRun(); + const collections = await loadCollections(); + const stats: Record = {}; + + for (const [collection, records] of Object.entries(collections)) { + stats[collection] = await importRaw(runId, collection, records); + console.log(`${collection}: ${stats[collection]} raw records imported`); + } + + await normalizeAll(runId, collections); + + await pool.query( + `update public.pb_import_runs set status = 'completed', stats = $2, finished_at = now() where id = $1`, + [runId, JSON.stringify(stats)], + ); + + console.log(`Import completed. runId=${runId}`); +} + +main() + .catch(async error => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await closeDb(); + }); diff --git a/scripts/import-pocketbase/src/pb-schema.ts b/scripts/import-pocketbase/src/pb-schema.ts new file mode 100644 index 00000000..4bd65698 --- /dev/null +++ b/scripts/import-pocketbase/src/pb-schema.ts @@ -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 || []; +} diff --git a/scripts/import-pocketbase/src/risk-report.ts b/scripts/import-pocketbase/src/risk-report.ts new file mode 100644 index 00000000..f39c81f8 --- /dev/null +++ b/scripts/import-pocketbase/src/risk-report.ts @@ -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 = { 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}`); +} diff --git a/scripts/import-pocketbase/src/validate-import.ts b/scripts/import-pocketbase/src/validate-import.ts new file mode 100644 index 00000000..d841076c --- /dev/null +++ b/scripts/import-pocketbase/src/validate-import.ts @@ -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 { + 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 { + 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(); + }); diff --git a/scripts/import-pocketbase/tsconfig.json b/scripts/import-pocketbase/tsconfig.json new file mode 100644 index 00000000..31917cd2 --- /dev/null +++ b/scripts/import-pocketbase/tsconfig.json @@ -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"] +} diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js new file mode 100644 index 00000000..07af9226 --- /dev/null +++ b/scripts/smoke-seed.js @@ -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; +}); diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..36629793 --- /dev/null +++ b/supabase/config.toml @@ -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 diff --git a/supabase/migrations/202606210001_core_multitenant_schema.sql b/supabase/migrations/202606210001_core_multitenant_schema.sql new file mode 100644 index 00000000..c6660bc0 --- /dev/null +++ b/supabase/migrations/202606210001_core_multitenant_schema.sql @@ -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 $$; diff --git a/supabase/migrations/202606210002_commercial_domain_extensions.sql b/supabase/migrations/202606210002_commercial_domain_extensions.sql new file mode 100644 index 00000000..9268d293 --- /dev/null +++ b/supabase/migrations/202606210002_commercial_domain_extensions.sql @@ -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 $$; diff --git a/supabase/migrations/202606210003_learning_content_import_extensions.sql b/supabase/migrations/202606210003_learning_content_import_extensions.sql new file mode 100644 index 00000000..1ae4a377 --- /dev/null +++ b/supabase/migrations/202606210003_learning_content_import_extensions.sql @@ -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 $$; diff --git a/supabase/migrations/202606210004_auth_china_login_extensions.sql b/supabase/migrations/202606210004_auth_china_login_extensions.sql new file mode 100644 index 00000000..3505b615 --- /dev/null +++ b/supabase/migrations/202606210004_auth_china_login_extensions.sql @@ -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(); diff --git a/supabase/migrations/202606210005_platform_admin_billing.sql b/supabase/migrations/202606210005_platform_admin_billing.sql new file mode 100644 index 00000000..74803154 --- /dev/null +++ b/supabase/migrations/202606210005_platform_admin_billing.sql @@ -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(); diff --git a/supabase/migrations/202606210006_growth_referral_crm.sql b/supabase/migrations/202606210006_growth_referral_crm.sql new file mode 100644 index 00000000..63147697 --- /dev/null +++ b/supabase/migrations/202606210006_growth_referral_crm.sql @@ -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(); diff --git a/supabase/migrations/202606210007_content_import_assets.sql b/supabase/migrations/202606210007_content_import_assets.sql new file mode 100644 index 00000000..a5eee83a --- /dev/null +++ b/supabase/migrations/202606210007_content_import_assets.sql @@ -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 $$; diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 00000000..589c5801 --- /dev/null +++ b/supabase/seed.sql @@ -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;