feat: scaffold supabase multi-tenant backend

This commit is contained in:
Codex
2026-06-21 21:54:43 +08:00
commit c9c767c7bd
99 changed files with 36660 additions and 0 deletions

4
apps/api/.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=8787
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
DEFAULT_TENANT_SLUG=master
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173

34
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
RUN npm ci --workspaces --include-workspace-root
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY apps/api ./apps/api
COPY packages ./packages
RUN npm run build:api
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
EXPOSE 8787
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]

729
apps/api/package-lock.json generated Normal file
View File

@@ -0,0 +1,729 @@
{
"name": "@tiku-saas/api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/api",
"version": "0.1.0",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

21
apps/api/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@tiku-saas/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/apps/api/src/server.js",
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,37 @@
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
export interface ApiConfig {
nodeEnv: string;
port: number;
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
platformAdminApiKey: string;
isProduction: boolean;
}
loadDotenv();
const isProduction = envString('NODE_ENV', 'development') === 'production';
export const config: ApiConfig = {
nodeEnv: envString('NODE_ENV', 'development'),
port: envNumber('PORT', 8787),
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
corsOrigins: envList('CORS_ORIGIN', '*'),
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
isProduction,
};

35
apps/api/src/core/db.ts Normal file
View File

@@ -0,0 +1,35 @@
import type pg from 'pg';
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../../packages/db/src/index.js';
import { config } from './config.js';
export const pool = createPool({
connectionString: config.databaseUrl,
max: 10,
});
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
return runQuery<T>(pool, sql, params);
}
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
return runQueryOne<T>(pool, sql, params);
}
export async function closePool() {
await pool.end();
}
export async function transaction<T>(callback: (client: pg.PoolClient) => Promise<T>) {
const client = await pool.connect();
try {
await client.query('begin');
const result = await callback(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}

68
apps/api/src/core/http.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { URL } from 'node:url';
import { config } from './config.js';
export interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
url: URL;
}
export type Handler = (ctx: RequestContext) => Promise<unknown>;
export class HttpError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code = 'HTTP_ERROR',
) {
super(message);
}
}
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify(body));
}
export function getHeader(req: IncomingMessage, name: string): string {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value[0] || '';
return value || '';
}
export function applyCors(req: IncomingMessage, res: ServerResponse) {
const origin = getHeader(req, 'origin');
const allowAll = config.corsOrigins.includes('*');
if (allowAll) {
res.setHeader('access-control-allow-origin', '*');
} else if (origin && config.corsOrigins.includes(origin)) {
res.setHeader('access-control-allow-origin', origin);
res.setHeader('vary', 'origin');
}
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
}
export function routeKey(method: string | undefined, pathname: string) {
return `${method || 'GET'} ${pathname}`;
}
export function publicErrorBody(error: unknown) {
if (error instanceof HttpError) {
return {
statusCode: error.statusCode,
body: { error: error.message, code: error.code },
};
}
const message = error instanceof Error ? error.message : 'Unknown error';
return {
statusCode: 500,
body: {
error: config.isProduction ? 'Internal server error' : message,
code: 'INTERNAL_ERROR',
},
};
}

View File

@@ -0,0 +1,87 @@
import { config } from './config.js';
import { getHeader, HttpError, type RequestContext } from './http.js';
export type JsonObject = Record<string, unknown>;
export function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
}
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
const userId =
getHeader(ctx.req, 'x-user-id') ||
ctx.url.searchParams.get('userId') ||
(typeof body?.userId === 'string' ? body.userId : '');
if (!userId) {
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
}
return userId;
}
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
export function stringParam(ctx: RequestContext, name: string) {
return ctx.url.searchParams.get(name)?.trim() || '';
}
export async function readJsonBody(ctx: RequestContext): Promise<JsonObject> {
const chunks: Buffer[] = [];
for await (const chunk of ctx.req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
}
return parsed as JsonObject;
} catch (error) {
if (error instanceof HttpError) throw error;
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON_BODY');
}
}
export function requiredString(body: JsonObject, key: string) {
const value = body[key];
if (typeof value !== 'string' || !value.trim()) {
throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
}
return value.trim();
}
export function optionalString(body: JsonObject, key: string) {
const value = body[key];
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
export function optionalInteger(body: JsonObject, key: string, fallback: number) {
const value = Number(body[key] ?? fallback);
return Number.isFinite(value) ? Math.trunc(value) : fallback;
}
export function optionalStringArray(body: JsonObject, key: string): string[] {
const value = body[key];
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(Boolean);
}
export function requirePlatformAdmin(ctx: RequestContext) {
const provided = getHeader(ctx.req, 'x-platform-admin-key');
if (!provided || provided !== config.platformAdminApiKey) {
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}
}

View File

@@ -0,0 +1,44 @@
import type { Handler } from './http.js';
import { routeKey } from './http.js';
import { authRoutes } from '../features/auth/index.js';
import { catalogRoutes } from '../features/catalog/index.js';
import { commerceRoutes } from '../features/commerce/index.js';
import { healthRoutes } from '../features/health/index.js';
import { learningRoutes } from '../features/learning/index.js';
import { platformAdminRoutes } from '../features/platform-admin/index.js';
import { profileRoutes } from '../features/profile/index.js';
import { referralRoutes } from '../features/referral/index.js';
import { scorelineRoutes } from '../features/scoreline/index.js';
import { tenantAdminRoutes } from '../features/tenant-admin/index.js';
import { tenantContentRoutes } from '../features/tenant-content/index.js';
import { tenantRoutes } from '../features/tenant/index.js';
import { videoRoutes } from '../features/video/index.js';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export type RouteDefinition = [method: HttpMethod, path: string, handler: Handler];
export function createRouter(definitions: RouteDefinition[] = allRoutes) {
const routes = new Map<string, Handler>();
for (const [method, path, handler] of definitions) {
routes.set(routeKey(method, path), handler);
}
return routes;
}
const allRoutes: RouteDefinition[] = [
...healthRoutes,
...authRoutes,
...tenantRoutes,
...catalogRoutes,
...learningRoutes,
...profileRoutes,
...scorelineRoutes,
...videoRoutes,
...commerceRoutes,
...referralRoutes,
...platformAdminRoutes,
...tenantAdminRoutes,
...tenantContentRoutes,
];

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
sendSmsCodeRoute,
verifySmsCodeRoute,
} from './routes.js';
export const authRoutes: RouteDefinition[] = [
['POST', '/api/auth/sms/send', sendSmsCodeRoute],
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
['GET', '/api/auth/me', meRoute],
['POST', '/api/auth/logout', logoutRoute],
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
];

View File

@@ -0,0 +1,52 @@
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq';
export interface SmsSendInput {
tenantId: string;
phone: string;
code: string;
purpose: string;
metadata: Record<string, unknown>;
}
export interface SmsSendResult {
provider: SmsProviderName;
status: 'sent' | 'mocked';
providerMessageId?: string;
raw?: Record<string, unknown>;
}
export interface SmsProvider {
name: SmsProviderName;
send(input: SmsSendInput): Promise<SmsSendResult>;
}
class MockSmsProvider implements SmsProvider {
readonly name = 'mock' as const;
async send(): Promise<SmsSendResult> {
return {
provider: this.name,
status: 'mocked',
raw: { localOnly: true },
};
}
}
class NotConfiguredSmsProvider implements SmsProvider {
constructor(readonly name: SmsProviderName) {}
async send(): Promise<SmsSendResult> {
throw new Error(`${this.name} SMS provider is not configured yet`);
}
}
export function createSmsProvider(name: string): SmsProvider {
if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun');
if (name === 'tencent') return new NotConfiguredSmsProvider('tencent');
return new MockSmsProvider();
}
export function supportedOAuthProviders(): OAuthProviderName[] {
return ['wechat_web', 'wechat_miniapp', 'qq'];
}

View File

@@ -0,0 +1,377 @@
import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { query, transaction } from '../../core/db.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
import { createSmsProvider } from './providers.js';
import {
assertChinaPhone,
bearerTokenFrom,
clientIpFrom,
createLoginSession,
findUserBySessionToken,
generateSmsCode,
hashSmsCode,
normalizePurpose,
upsertPhoneUser,
userAgentFrom,
writeLoginEvent,
} from './service.js';
interface SmsCodeRow {
id: string;
codeHash: string;
attempts: number;
expiresAt: string;
status: string;
}
interface CooldownRow {
createdAt: string;
}
type SmsVerifyResult =
| {
ok: false;
statusCode: number;
message: string;
code: string;
}
| {
ok: true;
verified: true;
purpose: string;
phone: string;
user?: unknown;
isNewUser?: boolean;
session?: unknown;
};
function hashEquals(left: string, right: string) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function jsonObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
async function activeSmsProviderName(tenantId: string) {
if (config.authSmsProvider !== 'mock') return config.authSmsProvider;
const rows = await query<{ provider: string }>(
`
select provider
from public.tenant_auth_providers
where tenant_id = $1
and provider in ('aliyun', 'tencent', 'mock')
and status in ('active', 'testing')
order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end
limit 1
`,
[tenantId],
);
return rows[0]?.provider || config.authSmsProvider;
}
export async function sendSmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const metadata = jsonObject(body.metadata);
const cooldownRows = await query<CooldownRow>(
`
select created_at as "createdAt"
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
and created_at > now() - ($4::text || ' seconds')::interval
order by created_at desc
limit 1
`,
[tenantId, phone, purpose, config.authSmsCooldownSeconds],
);
const cooldownRow = cooldownRows[0];
if (cooldownRow) {
const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
}
const providerName = await activeSmsProviderName(tenantId);
const provider = createSmsProvider(providerName);
if (config.isProduction && provider.name === 'mock') {
throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
}
const code = generateSmsCode();
const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata });
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
const item = await transaction(async client => {
const insertResult = await client.query(
`
insert into public.sms_verification_codes (
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
`,
[
tenantId,
phone,
purpose,
codeHash,
providerResult.provider,
expiresAt,
ipAddress || null,
userAgent || null,
JSON.stringify({
...metadata,
providerStatus: providerResult.status,
providerMessageId: providerResult.providerMessageId || null,
}),
],
);
await writeLoginEvent(client, {
tenantId,
provider: `sms:${providerResult.provider}`,
identifier: phone,
result: 'sent',
ipAddress,
userAgent,
metadata: { purpose },
});
return insertResult.rows[0];
});
return {
item,
expireIn: config.authCodeTtlSeconds,
cooldown: config.authSmsCooldownSeconds,
debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
};
}
export async function verifySmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const code = requiredString(body, 'code');
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
const result = await transaction<SmsVerifyResult>(async client => {
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[tenantId, phone, purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress,
userAgent,
metadata: { purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
if (purpose !== 'login') {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: { purpose, sessionIssued: false },
});
return { ok: true, verified: true, purpose, phone };
}
const { user, isNewUser } = await upsertPhoneUser(client, { tenantId, phone });
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider: 'sms',
ipAddress,
userAgent,
metadata: { purpose },
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: { purpose, isNewUser },
});
return { ok: true, verified: true, purpose, phone, user, isNewUser, session };
});
if (!result.ok) {
throw new HttpError(result.statusCode, result.message, result.code);
}
return result;
}
export async function meRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) {
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
}
const session = await findUserBySessionToken(token);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
if (session.tenantId !== tenantId) {
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
}
return {
user: {
id: session.id,
username: session.username,
phone: session.phone,
name: session.name,
avatarUrl: session.avatarUrl,
primaryRole: session.primaryRole,
createdAt: session.createdAt,
},
session: {
id: session.sessionId,
expiresAt: session.sessionExpiresAt,
},
};
}
export async function logoutRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) return { ok: true };
const session = await findUserBySessionToken(token);
if (session && session.tenantId === tenantId) {
await query(
`
update app_private.auth_sessions
set revoked_at = now(), updated_at = now()
where id = $1
`,
[session.sessionId],
);
}
return { ok: true };
}
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
throw new HttpError(
501,
`${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
'PROVIDER_NOT_CONFIGURED',
);
}

View File

@@ -0,0 +1,281 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { config } from '../../core/config.js';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
export interface PlatformUserSummary {
id: string;
username: string | null;
phone: string | null;
name: string | null;
avatarUrl: string | null;
primaryRole: string;
createdAt: string;
}
export interface LoginSessionSummary {
token: string;
expiresAt: string;
}
export function clientIpFrom(ctx: RequestContext) {
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
}
export function userAgentFrom(ctx: RequestContext) {
return getHeader(ctx.req, 'user-agent');
}
export function normalizeChinaPhone(phone: string) {
return phone.replace(/\s+/g, '').replace(/^\+?86/, '');
}
export function assertChinaPhone(phone: string) {
const normalized = normalizeChinaPhone(phone);
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE');
}
return normalized;
}
export function normalizePurpose(value: string) {
if (['login', 'bind_phone', 'reset_password'].includes(value)) return value;
throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE');
}
export function generateSmsCode() {
return crypto.randomInt(100000, 1000000).toString();
}
export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) {
return crypto
.createHmac('sha256', config.authCodePepper)
.update([tenantId, phone, purpose, code].join(':'))
.digest('hex');
}
export function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
export async function findUserBySessionToken(token: string) {
const tokenHash = hashSessionToken(token);
return queryOne<
PlatformUserSummary & {
tenantId: string;
sessionId: string;
sessionExpiresAt: string;
}
>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
and s.revoked_at is null
and s.expires_at > now()
limit 1
`,
[tokenHash],
);
}
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}
export async function createLoginSession(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
provider: string;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
},
): Promise<LoginSessionSummary> {
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
await client.query(
`
insert into app_private.auth_sessions (
tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb)
`,
[
input.tenantId,
input.userId,
tokenHash,
input.provider,
expiresAt,
input.ipAddress || null,
input.userAgent || null,
JSON.stringify(input.metadata || {}),
],
);
return { token, expiresAt };
}
export async function upsertPhoneUser(
client: pg.PoolClient,
input: {
tenantId: string;
phone: string;
},
) {
const existing = await client.query<PlatformUserSummary>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt"
from public.user_identities i
join public.platform_users u on u.id = i.user_id
where i.provider = 'phone'
and i.provider_subject = $1
limit 1
`,
[input.phone],
);
if (existing.rows[0]) {
const user = existing.rows[0];
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: false };
}
const legacyPhoneUser = await client.query<PlatformUserSummary>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt"
from public.platform_users u
where u.phone = $1
order by u.created_at asc
limit 1
`,
[input.phone],
);
if (legacyPhoneUser.rows[0]) {
const user = legacyPhoneUser.rows[0];
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set user_id = excluded.user_id,
phone = excluded.phone,
updated_at = now()
`,
[user.id, input.phone],
);
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: false };
}
const userResult = await client.query<PlatformUserSummary>(
`
insert into public.platform_users (username, phone, primary_role, raw_profile)
values ($1, $2, 'student', $3::jsonb)
returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
`,
[`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })],
);
const user = userResult.rows[0];
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set user_id = excluded.user_id,
phone = excluded.phone,
updated_at = now()
`,
[user.id, input.phone],
);
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: true };
}
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status)
values ($1, $2, 'student', 'active')
on conflict (tenant_id, user_id, role)
do update set status = 'active', updated_at = now()
`,
[tenantId, userId],
);
await client.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values ($1, $2, $3::jsonb, '{}'::jsonb)
on conflict (tenant_id, user_id) do nothing
`,
[
tenantId,
userId,
JSON.stringify({
totalAnswered: 0,
correctCount: 0,
wrongCount: 0,
studyDays: 1,
}),
],
);
}
export async function writeLoginEvent(
client: pg.PoolClient,
input: {
tenantId: string;
userId?: string | null;
provider: string;
identifier?: string;
result: 'sent' | 'success' | 'failed' | 'blocked';
failureCode?: string | null;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.auth_login_events (
tenant_id, user_id, provider, identifier, result, failure_code,
ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`,
[
input.tenantId,
input.userId || null,
input.provider,
input.identifier || null,
input.result,
input.failureCode || null,
input.ipAddress || null,
input.userAgent || null,
JSON.stringify(input.metadata || {}),
],
);
}

View File

@@ -0,0 +1,202 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
interface CatalogAssetRow {
id: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
visibility: string;
regionId: string | null;
subjectId: string | null;
}
function optionalUserId(ctx: RequestContext) {
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
}
function hasUserContext(ctx: RequestContext) {
return !!optionalUserId(ctx);
}
function signedDownload(asset: CatalogAssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function hasActiveMembership(tenantId: string, userId: string) {
const row = await queryOne<{ id: string }>(
`
select id
from public.tenant_memberships
where tenant_id = $1 and user_id = $2 and status = 'active'
limit 1
`,
[tenantId, userId],
);
return !!row;
}
async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAssetRow) {
const now = new Date().toISOString();
const row = await queryOne<{ id: string }>(
`
select id
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
and (
scope_type = 'tenant'
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid)
)
limit 1
`,
[tenantId, userId, now, asset.regionId, asset.subjectId],
);
return !!row;
}
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
if (asset.visibility === 'private') {
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
}
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const member = await hasActiveMembership(tenantId, userId);
if (!member) {
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
}
if (asset.visibility === 'members') return { userId, svip: false };
const svip = await hasSvipAccess(tenantId, userId, asset);
if (!svip) {
throw new HttpError(403, 'SVIP entitlement is required for this asset', 'ASSET_SVIP_REQUIRED');
}
return { userId, svip: true };
}
export async function assetsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
const userPresent = hasUserContext(ctx);
const params: unknown[] = [tenantId];
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
if (!includeLocked || !userPresent) {
filters.push(`visibility in ('public', 'tenant')`);
}
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`(subject_id = $${params.length} or subject_id is null)`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`(category_id = $${params.length} or category_id is null)`);
}
params.push(limit);
const items = await query(
`
select id, asset_key as "assetKey", asset_type as "assetType",
title, category as "categoryLabel", description,
file_name as "fileName", preview_url as "previewUrl",
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
visibility, region_id as "regionId", subject_id as "subjectId",
category_id as "categoryId", node_id as "nodeId",
sort_order as "order", metadata, created_at as "createdAt",
updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function assetDownloadRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
if (!assetId) {
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
}
const asset = await queryOne<CatalogAssetRow>(
`
select id, asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
region_id as "regionId", subject_id as "subjectId"
from public.content_assets
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
const access = await assertAssetAccess(ctx, asset);
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, assetId],
);
return {
item: {
id: asset.id,
assetType: asset.assetType,
title: asset.title,
fileName: asset.fileName,
previewUrl: asset.previewUrl,
visibility: asset.visibility,
},
access,
download: signedDownload(asset, 900),
};
}

View File

@@ -0,0 +1,47 @@
import type { RouteDefinition } from '../../core/router.js';
import { assetDownloadRoute, assetsRoute } from './assets.js';
import {
announcementsRoute,
bannersRoute,
categoriesRoute,
faqsRoute,
handbookChaptersRoute,
handbookEntriesRoute,
handbookSubjectsRoute,
majorsRoute,
moduleNodesRoute,
productsRoute,
questionsRoute,
regionModulesRoute,
regionsRoute,
schoolsRoute,
subjectsRoute,
svipPlansRoute,
timelinesRoute,
vocabularyUnitsRoute,
vocabularyWordsRoute,
} from './routes.js';
export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/regions', regionsRoute],
['GET', '/api/catalog/region-modules', regionModulesRoute],
['GET', '/api/catalog/module-nodes', moduleNodesRoute],
['GET', '/api/catalog/schools', schoolsRoute],
['GET', '/api/catalog/majors', majorsRoute],
['GET', '/api/catalog/subjects', subjectsRoute],
['GET', '/api/catalog/categories', categoriesRoute],
['GET', '/api/catalog/questions', questionsRoute],
['GET', '/api/catalog/assets', assetsRoute],
['GET', '/api/catalog/assets/download', assetDownloadRoute],
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
['GET', '/api/catalog/handbook-chapters', handbookChaptersRoute],
['GET', '/api/catalog/handbook-entries', handbookEntriesRoute],
['GET', '/api/catalog/banners', bannersRoute],
['GET', '/api/catalog/faqs', faqsRoute],
['GET', '/api/catalog/announcements', announcementsRoute],
['GET', '/api/catalog/products', productsRoute],
['GET', '/api/catalog/timelines', timelinesRoute],
['GET', '/api/catalog/svip-plans', svipPlansRoute],
];

View File

@@ -0,0 +1,580 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
}
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
export async function regionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const items = await query(
`
select id, legacy_id as "legacyId", name, code, short_name as "shortName",
full_name as "fullName", icon, pinyin, is_hot as "isHot",
is_active as "isActive", sort_order as "order"
from public.regions
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
`,
[tenantId],
);
return { items };
}
export async function regionModulesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId", name, type,
icon, color, text_color as "textColor", description, route,
is_primary_school_module as "isPrimarySchoolModule",
is_active as "isActive", sort_order as "order"
from public.region_modules
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function moduleNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const moduleId = ctx.url.searchParams.get('moduleId');
const parentId = ctx.url.searchParams.get('parentId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (moduleId) {
params.push(moduleId);
filters.push(`module_id = $${params.length}`);
}
if (parentId) {
params.push(parentId === 'root' ? null : parentId);
filters.push(parentId === 'root' ? 'parent_id is null' : `parent_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", module_id as "moduleId",
parent_id as "parentId", legacy_parent_id as "legacyParentId",
region_id as "regionId", type, name, path, sort_order as "order",
is_active as "isActive", metadata
from public.module_nodes
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function schoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
module_id as "moduleId", name,
professional_exam_date as "professionalExamDate",
metadata, created_at as "createdAt", updated_at as "updatedAt"
from public.schools
where ${filters.join(' and ')}
order by name asc, created_at asc
`,
params,
);
return { items };
}
export async function majorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", name, description, study_tips as "studyTips",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.majors
where ${filters.join(' and ')}
order by sort_order asc, name asc
`,
params,
);
return { items };
}
export async function subjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const majorId = ctx.url.searchParams.get('majorId');
const moduleId = ctx.url.searchParams.get('moduleId');
const type = ctx.url.searchParams.get('type');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
if (majorId) {
params.push(majorId);
filters.push(`major_id = $${params.length}`);
}
if (moduleId) {
params.push(moduleId);
filters.push(`module_id = $${params.length}`);
}
if (type) {
params.push(type);
filters.push(`type = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
module_id as "moduleId", school_id as "schoolId",
major_id as "majorId", node_id as "nodeId", name, type,
major_legacy_ids as "majorLegacyIds", icon, description, stats,
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.subjects
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function categoriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const nodeId = ctx.url.searchParams.get('nodeId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", subject_id as "subjectId",
node_id as "nodeId", name, category_type as "categoryType",
sort_order as "order", svip_question_limit as "svipQuestionLimit",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.categories
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function questionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const categoryId = ctx.url.searchParams.get('categoryId');
const nodeId = ctx.url.searchParams.get('nodeId');
const limit = intParam(ctx, 'limit', 200, 500);
const params: unknown[] = [tenantId];
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
if (subjectId) {
params.push(subjectId);
filters.push(`q.subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`q.category_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`q.node_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
q.category_id as "categoryId", q.node_id as "nodeId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
where ${filters.join(' and ')}
order by q.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function vocabularyUnitsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function vocabularyWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const unitId = ctx.url.searchParams.get('unitId');
const limit = intParam(ctx, 'limit', 1000, 2000);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (unitId) {
params.push(unitId);
filters.push(`unit_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", unit_id as "unitId",
word, phonetic, meaning, example,
example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order",
is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words
where ${filters.join(' and ')}
order by sort_order asc, word asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function handbookSubjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, type, icon, color, description, sort_order as "order",
is_active as "isActive", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_subjects
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function handbookChaptersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", subject_id as "subjectId",
name, description, sort_order as "order",
is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_chapters
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function handbookEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const chapterId = ctx.url.searchParams.get('chapterId');
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (chapterId) {
params.push(chapterId);
filters.push(`chapter_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", chapter_id as "chapterId",
title, summary, ${includeContent ? 'content' : 'null::text as content'},
tags, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function bannersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
title, subtitle, content, button_text as "buttonText",
button_link as "buttonLink", bg_color as "bgColor",
border_color as "borderColor", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.banners
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function faqsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
question, answer, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.faqs
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function announcementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const items = await query(
`
select id, legacy_id as "legacyId", content, link,
bg_color as "bgColor", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.announcements
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
`,
[tenantId],
);
return { items };
}
export async function productsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
title, price_label as "price", link, type, tags, cover,
preview_iframe as "previewIframe", detail_images as "detailImages",
sort_order as "order", status, created_at as "createdAt",
updated_at as "updatedAt"
from public.products
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
`,
params,
);
return { items };
}
export async function timelinesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", type, title, description,
event_date as "eventDate", link, sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.timelines
where ${filters.join(' and ')}
order by event_date asc nulls last, sort_order asc
`,
params,
);
return { items };
}
export async function svipPlansRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, price_cents as "priceCents",
(price_cents::numeric / 100.0) as price,
original_price_cents as "originalPriceCents",
case when original_price_cents is null then null else (original_price_cents::numeric / 100.0) end as "originalPrice",
days, description as "desc", per_day_label as "perDay",
badge, recommended, coupon_only as "couponOnly",
vp_product_id as "vpProductId", vp_enabled as "vpEnabled",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.svip_plans
where ${filters.join(' and ')}
order by sort_order asc, price_cents asc
`,
params,
);
return { items };
}

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
confirmManualPaymentRoute,
createOrderRoute,
entitlementCheckRoute,
entitlementsRoute,
ordersRoute,
redeemActivationCodeRoute,
} from './routes.js';
export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/orders', createOrderRoute],
['GET', '/api/commerce/orders', ordersRoute],
['GET', '/api/commerce/entitlements', entitlementsRoute],
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
];

View File

@@ -0,0 +1,385 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalInteger,
optionalString,
readJsonBody,
requiredString,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { createOrderNo, grantSvipEntitlement } from './service.js';
interface PlanRow {
id: string;
name: string;
price_cents: number;
days: number;
region_id: string | null;
}
interface OrderRow {
id: string;
order_no: string;
status: string;
amount_cents: number;
days: number | null;
user_id: string | null;
region_id: string | null;
}
interface EntitlementRow {
id: string;
entitlementType: string;
scopeType: string;
scopeId: string | null;
sourceType: string;
sourceId: string | null;
startsAt: string;
expiresAt: string | null;
status: string;
metadata: Record<string, unknown>;
createdAt: string;
}
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const planId = requiredString(body, 'planId');
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
const payMethod = optionalString(body, 'payMethod') || 'manual';
const payProvider = optionalString(body, 'payProvider') || 'manual';
const regionId = optionalString(body, 'regionId') || null;
const plan = await queryOne<PlanRow>(
`
select id, name, price_cents, days, region_id
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, planId],
);
if (!plan) {
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
}
const finalRegionId = regionId || plan.region_id;
const amountCents = Math.max(0, plan.price_cents * quantity);
const days = Math.max(0, plan.days * quantity);
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
const item = await transaction(async client => {
const orderResult = await client.query(
`
insert into public.orders (
tenant_id, user_id, order_no, status, product_type, product_name,
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
)
values ($1, $2, $3, 'pending', 'svip', $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
returning id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
plan_id as "planId", days, region_id as "regionId",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
orderNo,
`${plan.name}${quantity > 1 ? ` x${quantity}` : ''}`,
amountCents,
payMethod,
payProvider,
planId,
days,
finalRegionId,
JSON.stringify({ quantity, request: body }),
],
);
await client.query(
`
insert into public.order_items (tenant_id, order_id, item_type, item_id, name, quantity, unit_amount_cents, total_amount_cents, metadata)
values ($1, $2, 'svip_plan', $3, $4, $5, $6, $7, $8::jsonb)
`,
[
tenantId,
orderResult.rows[0].id,
planId,
plan.name,
quantity,
plan.price_cents,
amountCents,
JSON.stringify({ days: plan.days, totalDays: days }),
],
);
await client.query(
`
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
`,
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
);
return orderResult.rows[0];
});
return { item };
}
export async function ordersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query<EntitlementRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days,
region_id as "regionId", paid_at as "paidAt",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2
order by created_at desc
limit $3
`,
[tenantId, userId, limit],
);
return { items };
}
export async function entitlementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const now = new Date().toISOString();
const items: EntitlementRow[] = await query<EntitlementRow>(
`
select id, entitlement_type as "entitlementType",
scope_type as "scopeType", scope_id as "scopeId",
source_type as "sourceType", source_id as "sourceId",
starts_at as "startsAt", expires_at as "expiresAt",
status, metadata, created_at as "createdAt"
from public.entitlements
where tenant_id = $1 and user_id = $2
order by expires_at desc nulls first, created_at desc
`,
[tenantId, userId],
);
const activeSvip = await queryOne<{
expiresAt: string | null;
scopeType: string;
scopeId: string | null;
}>(
`
select expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
order by expires_at desc nulls first
limit 1
`,
[tenantId, userId, now],
);
const svipExpiresAt =
items.find(item => item.entitlementType === 'svip' && item.status === 'active' && !item.expiresAt)?.expiresAt ||
items
.filter(item => item.entitlementType === 'svip' && item.status === 'active' && item.expiresAt)
.map(item => item.expiresAt)
.sort()
.at(-1) ||
activeSvip?.expiresAt ||
null;
return {
items,
summary: {
isSvip: !!activeSvip,
svipExpiresAt,
svipScopeType: activeSvip?.scopeType || null,
svipScopeId: activeSvip?.scopeId || null,
},
};
}
export async function entitlementCheckRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId') || '';
const now = new Date().toISOString();
const row = await queryOne<{
id: string;
expiresAt: string | null;
scopeType: string;
scopeId: string | null;
}>(
`
select id, expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
and (
scope_type = 'tenant'
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
)
order by case when scope_type = 'region' then 0 else 1 end, expires_at desc nulls first
limit 1
`,
[tenantId, userId, now, regionId || null],
);
return {
allowed: !!row,
entitlement: row || null,
};
}
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const orderNo = requiredString(body, 'orderNo');
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
const amountCents = optionalInteger(body, 'amountCents', -1);
const item = await transaction(async client => {
const orderResult = await client.query<OrderRow>(
`
select id, order_no, status, amount_cents, days, user_id, region_id
from public.orders
where tenant_id = $1 and order_no = $2
limit 1
for update
`,
[tenantId, orderNo],
);
const order = orderResult.rows[0];
if (!order) {
throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
}
if (!order.user_id) {
throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
}
if (amountCents >= 0 && amountCents !== order.amount_cents) {
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
}
if (order.status === 'paid') {
return { orderNo, status: 'paid', idempotent: true };
}
await client.query(
`
update public.orders
set status = 'paid', trade_no = $3, paid_at = now(), updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, order.id, providerTradeNo],
);
await client.query(
`
update public.payments
set status = 'paid', provider_trade_no = $3, paid_at = now(), updated_at = now(),
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb
where tenant_id = $1 and order_id = $2
`,
[tenantId, order.id, providerTradeNo, JSON.stringify({ manualConfirm: body })],
);
const entitlement = await grantSvipEntitlement(client, {
tenantId,
userId: order.user_id,
days: order.days || 0,
regionId: order.region_id,
sourceType: 'order',
sourceId: order.id,
metadata: { orderNo },
});
return { orderNo, status: 'paid', entitlement };
});
return { item };
}
export async function redeemActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const code = requiredString(body, 'code');
const regionId = optionalString(body, 'regionId') || null;
const item = await transaction(async client => {
const codeResult = await client.query<{
id: string;
code: string;
days: number;
is_used: boolean;
used_region_id: string | null;
}>(
`
select id, code, days, is_used, used_region_id
from public.activation_codes
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
for update
`,
[tenantId, code],
);
const activationCode = codeResult.rows[0];
if (!activationCode) {
throw new HttpError(404, 'Activation code not found', 'ACTIVATION_CODE_NOT_FOUND');
}
if (activationCode.is_used) {
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
}
const finalRegionId = regionId || activationCode.used_region_id;
await client.query(
`
update public.activation_codes
set is_used = true, used_by = $3, used_at = now(),
used_region_id = coalesce($4::uuid, used_region_id),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, activationCode.id, userId, finalRegionId],
);
const entitlement = await grantSvipEntitlement(client, {
tenantId,
userId,
days: activationCode.days,
regionId: finalRegionId,
sourceType: 'activation_code',
sourceId: activationCode.id,
legacySourceId: activationCode.code,
metadata: { code: activationCode.code },
});
return { code: activationCode.code, entitlement };
});
return { item };
}

View File

@@ -0,0 +1,80 @@
import type pg from 'pg';
interface EntitlementInput {
tenantId: string;
userId: string;
days: number;
regionId?: string | null;
sourceType: string;
sourceId?: string | null;
legacySourceId?: string | null;
metadata?: Record<string, unknown>;
}
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
const days = Math.max(0, Math.trunc(input.days || 0));
const scopeType = input.regionId ? 'region' : 'tenant';
const startsAtResult = await client.query<{ starts_at: string }>(
`
select greatest(
now(),
coalesce(max(expires_at), now())
) as starts_at
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and scope_type = $3
and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid)
and status = 'active'
`,
[input.tenantId, input.userId, scopeType, input.regionId || null],
);
const startsAt = startsAtResult.rows[0]?.starts_at;
const result = await client.query(
`
insert into public.entitlements (
tenant_id, user_id, entitlement_type, scope_type, scope_id,
source_type, source_id, legacy_source_id, starts_at, expires_at, status, metadata
)
values (
$1, $2, 'svip', $3, $4,
$5, $6, $7, $8::timestamptz,
$8::timestamptz + ($9::text || ' days')::interval,
'active', $10::jsonb
)
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
status, metadata
`,
[
input.tenantId,
input.userId,
scopeType,
input.regionId || null,
input.sourceType,
input.sourceId || null,
input.legacySourceId || null,
startsAt,
days,
JSON.stringify(input.metadata || {}),
],
);
return result.rows[0];
}
export function createOrderNo(prefix = 'SVIP') {
const now = new Date();
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}

View File

@@ -0,0 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import { healthRoute } from './routes.js';
export const healthRoutes: RouteDefinition[] = [
['GET', '/health', async () => healthRoute()],
];

View File

@@ -0,0 +1,11 @@
import { queryOne } from '../../core/db.js';
export async function healthRoute() {
const db = await queryOne<{ ok: number }>('select 1 as ok');
return {
ok: true,
service: 'tiku-saas-api',
db: db?.ok === 1 ? 'ok' : 'unknown',
time: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,28 @@
import type { RouteDefinition } from '../../core/router.js';
import {
createPracticeSessionRoute,
favoriteQuestionsRoute,
favoriteWordsRoute,
resolveWrongQuestionRoute,
submitAnswerRoute,
toggleFavoriteQuestionRoute,
toggleFavoriteWordRoute,
updateWordProgressRoute,
wordProgressRoute,
wordStatsRoute,
wrongQuestionsRoute,
} from './routes.js';
export const learningRoutes: RouteDefinition[] = [
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
['POST', '/api/learning/answers', submitAnswerRoute],
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],
];

View File

@@ -0,0 +1,453 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalInteger,
optionalString,
optionalStringArray,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
interface QuestionAnswerRow {
question_id: string;
question_version_id: string | null;
correct_option_index: number | null;
correct_option_indices: unknown;
answer_text: string | null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(item => item !== '');
}
function normalizeNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value
.map(item => Number(item))
.filter(item => Number.isFinite(item))
.map(item => Math.trunc(item))
.sort((a, b) => a - b);
}
function normalizeAnswerText(value: unknown) {
return String(value ?? '').trim().replace(/\s+/g, ' ');
}
function arraysEqual<T>(left: T[], right: T[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
const correctIndices = normalizeNumberArray(row.correct_option_indices);
if (correctIndices.length) {
return arraysEqual(
selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b),
correctIndices,
);
}
if (row.correct_option_index !== null && row.correct_option_index !== undefined) {
return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index;
}
if (row.answer_text) {
return normalizeAnswerText(answerText) === normalizeAnswerText(row.answer_text);
}
return null;
}
export async function createPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const mode = optionalString(body, 'mode') || 'chapter';
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const item = await queryOne(
`
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
values ($1, $2, $3, $4, $5, $6::jsonb)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
started_at as "startedAt", finished_at as "finishedAt", metadata
`,
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
);
return { item };
}
export async function submitAnswerRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const selectedOptions = optionalStringArray(body, 'selectedOptions');
const answerText = optionalString(body, 'answerText');
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
const question = await queryOne<QuestionAnswerRow>(
`
select q.id as question_id, q.current_version_id as question_version_id,
v.correct_option_index, v.correct_option_indices, v.answer_text
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
limit 1
`,
[tenantId, questionId],
);
if (!question) {
throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
}
const judged = judgeAnswer(question, selectedOptions, answerText);
const result = await transaction(async client => {
const answerResult = await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id, practice_session_id,
selected_options, answer_text, is_correct
)
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
returning id, question_id as "questionId", question_version_id as "questionVersionId",
selected_options as "selectedOptions", answer_text as "answerText",
is_correct as "isCorrect", answered_at as "answeredAt"
`,
[tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged],
);
if (judged === false) {
await client.query(
`
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
values ($1, $2, $3, 1, now(), null)
on conflict (tenant_id, user_id, question_id)
do update set wrong_count = public.wrong_questions.wrong_count + 1,
last_wrong_at = now(),
resolved_at = null
`,
[tenantId, userId, questionId],
);
} else if (judged === true) {
await client.query(
`
update public.wrong_questions
set resolved_at = coalesce(resolved_at, now())
where tenant_id = $1 and user_id = $2 and question_id = $3 and resolved_at is null
`,
[tenantId, userId, questionId],
);
}
await client.query(
`
update public.student_profiles
set questions_answered_today = questions_answered_today + 1,
stats = coalesce(stats, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
'totalAnswered', coalesce((stats->>'totalAnswered')::integer, 0) + 1,
'correctCount', case when $3::boolean is true then coalesce((stats->>'correctCount')::integer, 0) + 1 else null end,
'wrongCount', case when $3::boolean is false then coalesce((stats->>'wrongCount')::integer, 0) + 1 else null end
)),
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId, judged],
);
return answerResult.rows[0];
});
return { item: result };
}
export async function favoriteQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select fq.question_id as "questionId", fq.source, fq.created_at as "createdAt",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.type, q.type_label as "typeLabel", v.content
from public.favorite_questions fq
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where fq.tenant_id = $1 and fq.user_id = $2
order by fq.created_at desc
limit $3
`,
[tenantId, userId, limit],
);
return { items };
}
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const favorite = body.favorite !== false;
if (favorite) {
await query(
`
insert into public.favorite_questions (tenant_id, user_id, question_id, source)
values ($1, $2, $3, 'api')
on conflict (tenant_id, user_id, question_id) do nothing
`,
[tenantId, userId, questionId],
);
} else {
await query(
`
delete from public.favorite_questions
where tenant_id = $1 and user_id = $2 and question_id = $3
`,
[tenantId, userId, questionId],
);
}
return { ok: true, favorite };
}
export async function wrongQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.type, q.type_label as "typeLabel", v.content
from public.wrong_questions wq
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where wq.tenant_id = $1 and wq.user_id = $2
and ($3::boolean = false or wq.resolved_at is null)
order by wq.last_wrong_at desc
limit $4
`,
[tenantId, userId, unresolvedOnly, limit],
);
return { items };
}
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
await query(
`
update public.wrong_questions
set resolved_at = now()
where tenant_id = $1 and user_id = $2 and question_id = $3
`,
[tenantId, userId, questionId],
);
return { ok: true };
}
function normalizeWordStatus(value: string) {
if (['new', 'learning', 'mastered', 'reviewing'].includes(value)) return value;
return 'learning';
}
export async function wordProgressRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const status = stringParam(ctx, 'status');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select p.id, p.word_id as "wordId", p.status,
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
p.created_at as "createdAt", p.updated_at as "updatedAt",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
from public.user_word_progress p
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
where p.tenant_id = $1 and p.user_id = $2
and ($3::uuid is null or w.unit_id = $3::uuid)
and ($4::text = '' or p.status = $4::text)
order by p.updated_at desc
limit $5
`,
[tenantId, userId, unitId || null, status, limit],
);
return { items };
}
export async function updateWordProgressRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
const item = await transaction(async client => {
const word = await client.query<{ id: string }>(
`
select id from public.vocabulary_words
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, wordId],
);
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
const progress = await client.query(
`
insert into public.user_word_progress (
tenant_id, user_id, word_id, status, correct_count, wrong_count,
last_review_date, next_review_date
)
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
on conflict (tenant_id, user_id, word_id)
do update set status = excluded.status,
correct_count = public.user_word_progress.correct_count + $5,
wrong_count = public.user_word_progress.wrong_count + $6,
last_review_date = now(),
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
updated_at = now()
returning id, word_id as "wordId", status,
correct_count as "correctCount", wrong_count as "wrongCount",
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
created_at as "createdAt", updated_at as "updatedAt"
`,
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
);
await client.query(
`
update public.student_profiles
set mastered_words_count = (
select count(*) from public.user_word_progress
where tenant_id = $1 and user_id = $2 and status = 'mastered'
),
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return progress.rows[0];
});
return { item };
}
export async function favoriteWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select f.id, f.word_id as "wordId", f.note,
coalesce(f.favorited_at, f.created_at) as "favoritedAt",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
w.example, w.example_translation as "exampleTranslation",
w.difficulty, w.tags
from public.user_word_favorites f
join public.vocabulary_words w on w.id = f.word_id and w.tenant_id = f.tenant_id
where f.tenant_id = $1 and f.user_id = $2
and ($3::uuid is null or w.unit_id = $3::uuid)
order by coalesce(f.favorited_at, f.created_at) desc
limit $4
`,
[tenantId, userId, unitId || null, limit],
);
return { items };
}
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const favorite = body.favorite !== false;
const note = optionalString(body, 'note') || null;
if (favorite) {
await query(
`
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
values ($1, $2, $3, $4, now())
on conflict (tenant_id, user_id, word_id)
do update set note = coalesce(excluded.note, public.user_word_favorites.note),
favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
updated_at = now()
`,
[tenantId, userId, wordId, note],
);
} else {
await query(
`
delete from public.user_word_favorites
where tenant_id = $1 and user_id = $2 and word_id = $3
`,
[tenantId, userId, wordId],
);
}
return { ok: true, favorite };
}
export async function wordStatsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const item = await queryOne(
`
select
count(w.id)::integer as "totalWords",
count(p.word_id)::integer as "progressedWords",
count(p.word_id) filter (where p.status = 'mastered')::integer as "masteredWords",
count(p.word_id) filter (where p.status in ('learning', 'reviewing'))::integer as "learningWords",
count(p.word_id) filter (where p.last_review_date::date = current_date)::integer as "todayReviewed",
(
select count(*)
from public.user_word_favorites f
join public.vocabulary_words fw on fw.id = f.word_id and fw.tenant_id = f.tenant_id
where f.tenant_id = $1 and f.user_id = $2
and ($3::uuid is null or fw.unit_id = $3::uuid)
)::integer as "favoriteWords"
from public.vocabulary_words w
left join public.user_word_progress p
on p.word_id = w.id and p.tenant_id = w.tenant_id and p.user_id = $2
where w.tenant_id = $1 and w.is_active = true
and ($3::uuid is null or w.unit_id = $3::uuid)
`,
[tenantId, userId, unitId || null],
);
return { item };
}

View File

@@ -0,0 +1,34 @@
import type { RouteDefinition } from '../../core/router.js';
import {
confirmInvoicePaymentRoute,
createInvoiceRoute,
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantRoute,
platformOverviewRoute,
platformPlansRoute,
recordUsageRoute,
tenantDetailRoute,
tenantInvoicesRoute,
tenantsRoute,
tenantUsageRoute,
updateTenantStatusRoute,
upsertBillingProfileRoute,
} from './routes.js';
export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/overview', platformOverviewRoute],
['GET', '/api/platform-admin/plans', platformPlansRoute],
['GET', '/api/platform-admin/tenants', tenantsRoute],
['POST', '/api/platform-admin/tenants', createTenantRoute],
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
['GET', '/api/platform-admin/usage', tenantUsageRoute],
['POST', '/api/platform-admin/usage', recordUsageRoute],
];

View File

@@ -0,0 +1,871 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalString,
readJsonBody,
requiredString,
requirePlatformAdmin,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import {
centsFrom,
createInvoiceNo,
invoiceSubtotal,
normalizeHost,
normalizeInvoiceItems,
normalizeSlug,
quantityFrom,
recalculateInvoiceTotals,
} from './service.js';
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
function listQuery(ctx: RequestContext, name: string) {
return ctx.url.searchParams.get(name)?.trim() || '';
}
function toDateText(value: unknown) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
return String(value).slice(0, 10);
}
export async function platformOverviewRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
queryOne<{
totalTenants: string;
activeTenants: string;
suspendedTenants: string;
trialTenants: string;
pastDueTenants: string;
}>(
`
select count(*)::text as "totalTenants",
count(*) filter (where status = 'active')::text as "activeTenants",
count(*) filter (where status = 'suspended')::text as "suspendedTenants",
count(*) filter (where billing_status = 'trial')::text as "trialTenants",
count(*) filter (where billing_status = 'past_due')::text as "pastDueTenants"
from public.tenants
`,
),
queryOne<{
unpaidAmountCents: string;
paidAmountCents: string;
overdueInvoices: string;
}>(
`
select coalesce(sum(balance_cents) filter (where status in ('issued', 'overdue')), 0)::text as "unpaidAmountCents",
coalesce(sum(paid_cents), 0)::text as "paidAmountCents",
count(*) filter (where status = 'overdue' or (status = 'issued' and due_date < current_date))::text as "overdueInvoices"
from public.tenant_invoices
`,
),
queryOne<{ activeSubscriptions: string; expiringSoon: string }>(
`
select count(*) filter (where status in ('trial', 'active'))::text as "activeSubscriptions",
count(*) filter (where status in ('trial', 'active') and expires_at <= now() + interval '30 days')::text as "expiringSoon"
from public.tenant_subscriptions
`,
),
queryOne<{ students: string; questions: string; storageGb: string }>(
`
select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students,
coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions,
coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb"
from public.tenant_usage_records
where period_end >= current_date - interval '31 days'
`,
),
]);
return {
item: {
tenants: {
total: Number(tenantStats?.totalTenants || 0),
active: Number(tenantStats?.activeTenants || 0),
suspended: Number(tenantStats?.suspendedTenants || 0),
trial: Number(tenantStats?.trialTenants || 0),
pastDue: Number(tenantStats?.pastDueTenants || 0),
},
billing: {
unpaidAmountCents: Number(invoiceStats?.unpaidAmountCents || 0),
paidAmountCents: Number(invoiceStats?.paidAmountCents || 0),
overdueInvoices: Number(invoiceStats?.overdueInvoices || 0),
},
subscriptions: {
active: Number(subscriptionStats?.activeSubscriptions || 0),
expiringSoon: Number(subscriptionStats?.expiringSoon || 0),
},
usage: {
students: Number(usageStats?.students || 0),
questions: Number(usageStats?.questions || 0),
storageGb: Number(usageStats?.storageGb || 0),
},
},
};
}
export async function platformPlansRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
const items = await query(
`
select id, code, name, description, billing_cycle as "billingCycle",
base_amount_cents as "baseAmountCents", currency, included_quotas as "includedQuotas",
overage_prices as "overagePrices", feature_flags as "featureFlags",
status, sort_order as "sortOrder", created_at as "createdAt", updated_at as "updatedAt"
from public.platform_saas_plans
where ($1::boolean = true or status = 'active')
order by sort_order asc, created_at asc
`,
[includeArchived],
);
return { items };
}
export async function tenantsRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const status = listQuery(ctx, 'status');
const billingStatus = listQuery(ctx, 'billingStatus');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query(
`
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
b.brand_name as "brandName", b.logo_url as "logoUrl",
s.plan_code as "planCode", s.status as "subscriptionStatus",
s.expires_at as "subscriptionExpiresAt",
coalesce(i.open_balance_cents, 0) as "openBalanceCents"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join lateral (
select plan_code, status, expires_at
from public.tenant_subscriptions
where tenant_id = t.id
order by created_at desc
limit 1
) s on true
left join lateral (
select sum(balance_cents)::integer as open_balance_cents
from public.tenant_invoices
where tenant_id = t.id and status in ('issued', 'overdue')
) i on true
where ($1::text = '' or t.status = $1)
and ($2::text = '' or t.billing_status = $2)
and (
$3::text = ''
or t.slug::text ilike '%' || $3 || '%'
or t.name ilike '%' || $3 || '%'
or coalesce(t.legal_name, '') ilike '%' || $3 || '%'
)
order by t.created_at desc
limit $4
`,
[status, billingStatus, q, limit],
);
return { items };
}
export async function tenantDetailRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
const tenant = await queryOne(
`
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
b.brand_name as "brandName", b.short_name as "shortName", b.logo_url as "logoUrl",
b.service_wechat as "serviceWechat",
bp.billing_name as "billingName", bp.tax_id as "taxId",
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
bp.invoice_type as "invoiceType"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
where t.id = $1
limit 1
`,
[tenantId],
);
if (!tenant) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
const [domains, subscriptions, invoices, usage] = await Promise.all([
query(
`
select id, host::text, domain_type as "domainType", status, is_primary as "isPrimary",
verified_at as "verifiedAt", created_at as "createdAt"
from public.tenant_domains
where tenant_id = $1
order by is_primary desc, created_at asc
`,
[tenantId],
),
query(
`
select id, plan_code as "planCode", status, starts_at as "startsAt",
expires_at as "expiresAt", billing_cycle as "billingCycle",
amount_cents as "amountCents", metadata, created_at as "createdAt"
from public.tenant_subscriptions
where tenant_id = $1
order by created_at desc
limit 10
`,
[tenantId],
),
query(
`
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", due_date as "dueDate",
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
from public.tenant_invoices
where tenant_id = $1
order by created_at desc
limit 20
`,
[tenantId],
),
query(
`
select metric_key as "metricKey", metric_value as "metricValue",
period_start as "periodStart", period_end as "periodEnd", metadata,
created_at as "createdAt"
from public.tenant_usage_records
where tenant_id = $1
order by period_end desc, metric_key asc
limit 50
`,
[tenantId],
),
]);
return { item: { tenant, domains, subscriptions, invoices, usage } };
}
export async function createTenantRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const slug = normalizeSlug(requiredString(body, 'slug'));
const name = requiredString(body, 'name');
if (!slug) throw new HttpError(400, 'slug is invalid', 'INVALID_SLUG');
const planCode = optionalString(body, 'planCode') || 'starter_yearly';
const mode = optionalString(body, 'mode') || 'saas';
const billingStatus = optionalString(body, 'billingStatus') || 'trial';
const status = optionalString(body, 'status') || 'active';
const legalName = optionalString(body, 'legalName') || null;
const primaryHost = optionalString(body, 'primaryHost');
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
const item = await transaction(async client => {
const planResult = await client.query<{ code: string; billing_cycle: string; base_amount_cents: number }>(
`
select code, billing_cycle, base_amount_cents
from public.platform_saas_plans
where code = $1 and status = 'active'
limit 1
`,
[planCode],
);
const plan = planResult.rows[0];
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
const tenantResult = await client.query(
`
insert into public.tenants (slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
returning id, slug::text, name, legal_name as "legalName", status, mode,
billing_status as "billingStatus", metadata, created_at as "createdAt"
`,
[slug, name, legalName, status, mode, billingStatus, JSON.stringify(metadata)],
);
const tenant = tenantResult.rows[0];
await client.query(
`
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
values ($1, $2, $3, $4)
`,
[tenant.id, optionalString(body, 'brandName') || name, optionalString(body, 'shortName') || name, optionalString(body, 'slogan') || null],
);
await client.query(
`
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
values ($1, $2::jsonb, $3::jsonb, $4::jsonb)
`,
[
tenant.id,
jsonBodyValue(body.featureFlags || {}),
jsonBodyValue(body.adminFeatureFlags || {}),
jsonBodyValue(body.publicConfig || {}),
],
);
await client.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone,
contact_email, billing_address, invoice_title, invoice_type, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
`,
[
tenant.id,
typeof billing.billingName === 'string' ? billing.billingName : legalName || name,
typeof billing.taxId === 'string' ? billing.taxId : null,
typeof billing.contactName === 'string' ? billing.contactName : null,
typeof billing.contactPhone === 'string' ? billing.contactPhone : null,
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
jsonBodyValue(billing.metadata),
],
);
if (primaryHost) {
const host = normalizeHost(primaryHost);
if (host) {
await client.query(
`
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary, verification_token)
values ($1, $2, 'custom', 'pending', true, $3)
`,
[tenant.id, host, `tenant-${tenant.id.slice(0, 8)}-${Math.random().toString(36).slice(2, 10)}`],
);
}
}
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
const expiresAt =
optionalString(body, 'expiresAt') ||
(plan.billing_cycle === 'yearly'
? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString()
: null);
await client.query(
`
insert into public.tenant_subscriptions (
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
)
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
`,
[
tenant.id,
plan.code,
billingStatus === 'trial' ? 'trial' : 'active',
startsAt,
expiresAt,
plan.billing_cycle,
centsFrom(body.amountCents, plan.base_amount_cents),
JSON.stringify({ source: 'platform-admin:createTenant' }),
],
);
return tenant;
});
return { item };
}
export async function updateTenantStatusRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const status = optionalString(body, 'status');
const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
const item = await queryOne(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
return { item };
}
export async function upsertBillingProfileRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const item = await queryOne(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
optionalString(body, 'invoiceType') || 'none',
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
return { item };
}
export async function createSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const planCode = requiredString(body, 'planCode');
const plan = await queryOne<{ code: string; billingCycle: string; baseAmountCents: number }>(
`
select code, billing_cycle as "billingCycle", base_amount_cents as "baseAmountCents"
from public.platform_saas_plans
where code = $1 and status = 'active'
limit 1
`,
[planCode],
);
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
const expiresAt =
optionalString(body, 'expiresAt') ||
(plan.billingCycle === 'yearly' ? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString() : null);
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_subscriptions (
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
)
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
returning id, tenant_id as "tenantId", plan_code as "planCode", status,
starts_at as "startsAt", expires_at as "expiresAt",
billing_cycle as "billingCycle", amount_cents as "amountCents",
metadata, created_at as "createdAt"
`,
[
tenantId,
plan.code,
optionalString(body, 'status') || 'active',
startsAt,
expiresAt,
optionalString(body, 'billingCycle') || plan.billingCycle,
centsFrom(body.amountCents, plan.baseAmountCents),
jsonBodyValue(body.metadata),
],
);
await client.query(
`
update public.tenants
set billing_status = case when $2 = 'active' then 'active' else billing_status end,
updated_at = now()
where id = $1
`,
[tenantId, optionalString(body, 'status') || 'active'],
);
return result.rows[0];
});
return { item };
}
export async function tenantInvoicesRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const status = listQuery(ctx, 'status');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query(
`
select i.id, i.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType", i.status,
i.currency, i.total_cents as "totalCents", i.paid_cents as "paidCents",
i.balance_cents as "balanceCents", i.billing_period_start as "billingPeriodStart",
i.billing_period_end as "billingPeriodEnd", i.due_date as "dueDate",
i.issued_at as "issuedAt", i.paid_at as "paidAt", i.note,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.tenant_invoices i
join public.tenants t on t.id = i.tenant_id
where ($1::uuid is null or i.tenant_id = $1::uuid)
and ($2::text = '' or i.status = $2)
order by i.created_at desc
limit $3
`,
[tenantId || null, status, limit],
);
return { items };
}
interface CreateInvoiceInput {
tenantId: string;
invoiceNo?: string;
invoiceType?: string;
status?: string;
currency?: string;
discountCents?: number;
taxCents?: number;
billingPeriodStart?: string | null;
billingPeriodEnd?: string | null;
dueDate?: string | null;
note?: string | null;
metadata?: Record<string, unknown>;
items: ReturnType<typeof normalizeInvoiceItems>;
}
async function createInvoiceRecord(input: CreateInvoiceInput) {
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
const discountCents = centsFrom(input.discountCents, 0);
const taxCents = centsFrom(input.taxCents, 0);
const subtotalCents = invoiceSubtotal(input.items);
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
const invoiceNo = input.invoiceNo || createInvoiceNo();
return transaction(async client => {
const invoiceResult = await client.query(
`
insert into public.tenant_invoices (
tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
)
values (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, 0, $9,
$10::date, $11::date, $12::date,
case when $4 = 'draft' then null else now() end,
$13, $14::jsonb
)
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
invoice_type as "invoiceType", status, total_cents as "totalCents",
balance_cents as "balanceCents", due_date as "dueDate",
created_at as "createdAt"
`,
[
input.tenantId,
invoiceNo,
input.invoiceType || 'subscription',
input.status || 'issued',
input.currency || 'CNY',
subtotalCents,
discountCents,
taxCents,
totalCents,
input.billingPeriodStart || null,
input.billingPeriodEnd || null,
input.dueDate || null,
input.note || null,
JSON.stringify(input.metadata || {}),
],
);
const invoice = invoiceResult.rows[0];
for (const itemInput of input.items) {
await client.query(
`
insert into public.tenant_invoice_items (
tenant_id, invoice_id, item_type, description,
quantity, unit_amount_cents, amount_cents, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
input.tenantId,
invoice.id,
itemInput.itemType,
itemInput.description,
itemInput.quantity,
itemInput.unitAmountCents,
Math.round(itemInput.quantity * itemInput.unitAmountCents),
JSON.stringify(itemInput.metadata || {}),
],
);
}
await recalculateInvoiceTotals(client, invoice.id);
return invoice;
});
}
export async function createInvoiceRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const items = normalizeInvoiceItems(body.items);
const item = await createInvoiceRecord({
tenantId,
items,
invoiceNo: optionalString(body, 'invoiceNo') || undefined,
invoiceType: optionalString(body, 'invoiceType') || 'subscription',
status: optionalString(body, 'status') || 'issued',
currency: optionalString(body, 'currency') || 'CNY',
discountCents: centsFrom(body.discountCents, 0),
taxCents: centsFrom(body.taxCents, 0),
billingPeriodStart: optionalString(body, 'billingPeriodStart') || null,
billingPeriodEnd: optionalString(body, 'billingPeriodEnd') || null,
dueDate: optionalString(body, 'dueDate') || null,
note: optionalString(body, 'note') || null,
metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? (body.metadata as Record<string, unknown>) : {},
});
return { item };
}
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const invoiceId = requiredString(body, 'invoiceId');
const amountCents = centsFrom(body.amountCents, -1);
if (amountCents <= 0) throw new HttpError(400, 'amountCents must be positive', 'INVALID_AMOUNT');
const item = await transaction(async client => {
const invoiceResult = await client.query<{ id: string; balance_cents: number }>(
`
select id, balance_cents
from public.tenant_invoices
where tenant_id = $1 and id = $2
limit 1
for update
`,
[tenantId, invoiceId],
);
const invoice = invoiceResult.rows[0];
if (!invoice) throw new HttpError(404, 'Invoice not found', 'INVOICE_NOT_FOUND');
if (amountCents > invoice.balance_cents && invoice.balance_cents > 0) {
throw new HttpError(409, 'Payment amount exceeds invoice balance', 'PAYMENT_AMOUNT_EXCEEDS_BALANCE');
}
const paymentNo = optionalString(body, 'paymentNo') || createInvoiceNo('PAY');
await client.query(
`
insert into public.tenant_invoice_payments (
tenant_id, invoice_id, payment_no, provider, method, status,
amount_cents, paid_at, provider_trade_no, raw_payload
)
values ($1, $2, $3, $4, $5, 'paid', $6, coalesce($7::timestamptz, now()), $8, $9::jsonb)
`,
[
tenantId,
invoiceId,
paymentNo,
optionalString(body, 'provider') || 'manual',
optionalString(body, 'method') || 'manual',
amountCents,
optionalString(body, 'paidAt') || null,
optionalString(body, 'providerTradeNo') || null,
jsonBodyValue(body.rawPayload || body),
],
);
const updatedInvoice = await recalculateInvoiceTotals(client, invoiceId);
if (updatedInvoice?.status === 'paid') {
await client.query(
`
update public.tenants
set billing_status = 'active', updated_at = now()
where id = $1
`,
[tenantId],
);
await client.query(
`
update public.tenant_subscriptions
set status = 'active', updated_at = now()
where tenant_id = $1
and status in ('trial', 'past_due')
and id = (
select id
from public.tenant_subscriptions
where tenant_id = $1
order by created_at desc
limit 1
)
`,
[tenantId],
);
}
return updatedInvoice;
});
return { item };
}
export async function recordUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const metricKey = requiredString(body, 'metricKey');
const metricValue = quantityFrom(body.metricValue, 0);
const periodStart = optionalString(body, 'periodStart');
const periodEnd = optionalString(body, 'periodEnd');
if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD');
const item = await queryOne(
`
insert into public.tenant_usage_records (
tenant_id, metric_key, metric_value, period_start, period_end, metadata
)
values ($1, $2, $3, $4::date, $5::date, $6::jsonb)
returning id, tenant_id as "tenantId", metric_key as "metricKey",
metric_value as "metricValue", period_start as "periodStart",
period_end as "periodEnd", metadata, created_at as "createdAt"
`,
[tenantId, metricKey, metricValue, periodStart, periodEnd, jsonBodyValue(body.metadata)],
);
return { item };
}
export async function tenantUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select u.id, u.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
u.metric_key as "metricKey", u.metric_value as "metricValue",
u.period_start as "periodStart", u.period_end as "periodEnd",
u.metadata, u.created_at as "createdAt"
from public.tenant_usage_records u
join public.tenants t on t.id = u.tenant_id
where ($1::uuid is null or u.tenant_id = $1::uuid)
order by u.period_end desc, u.created_at desc
limit $2
`,
[tenantId || null, limit],
);
return { items };
}
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const subscriptionId = optionalString(body, 'subscriptionId');
const subscription = await queryOne<{
id: string;
planCode: string;
amountCents: number;
startsAt: string | null;
expiresAt: string | null;
}>(
`
select id, plan_code as "planCode", amount_cents as "amountCents",
starts_at as "startsAt", expires_at as "expiresAt"
from public.tenant_subscriptions
where tenant_id = $1
and ($2::uuid is null or id = $2::uuid)
order by created_at desc
limit 1
`,
[tenantId, subscriptionId || null],
);
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
const plan = await queryOne<{ name: string }>(
`
select name from public.platform_saas_plans
where code = $1
limit 1
`,
[subscription.planCode],
);
const item = await createInvoiceRecord({
tenantId,
invoiceType: 'subscription',
status: optionalString(body, 'status') || 'issued',
dueDate: optionalString(body, 'dueDate') || null,
billingPeriodStart: toDateText(subscription.startsAt),
billingPeriodEnd: toDateText(subscription.expiresAt),
note: optionalString(body, 'note') || null,
metadata: { source: 'subscription', subscriptionId: subscription.id },
items: [
{
itemType: 'subscription',
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
quantity: 1,
unitAmountCents: subscription.amountCents,
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
},
],
});
return { item };
}

View File

@@ -0,0 +1,118 @@
import type pg from 'pg';
export function createInvoiceNo(prefix = 'BILL') {
const now = new Date();
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}
export function normalizeSlug(slug: string) {
return slug
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function normalizeHost(host: string) {
return host.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || '';
}
export function centsFrom(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
}
export function quantityFrom(value: unknown, fallback = 1) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export interface InvoiceItemInput {
itemType: string;
description: string;
quantity: number;
unitAmountCents: number;
metadata?: Record<string, unknown>;
}
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
if (!Array.isArray(items)) return [];
return items
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => !!item)
.map(item => ({
itemType: typeof item.itemType === 'string' && item.itemType.trim() ? item.itemType.trim() : 'service_fee',
description: typeof item.description === 'string' && item.description.trim() ? item.description.trim() : '服务费',
quantity: quantityFrom(item.quantity, 1),
unitAmountCents: centsFrom(item.unitAmountCents, 0),
metadata: item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata) ? (item.metadata as Record<string, unknown>) : {},
}))
.filter(item => item.unitAmountCents > 0);
}
export function invoiceSubtotal(items: InvoiceItemInput[]) {
return items.reduce((sum, item) => sum + Math.round(item.quantity * item.unitAmountCents), 0);
}
export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: string) {
const itemResult = await client.query<{ subtotal: string }>(
`
select coalesce(sum(amount_cents), 0)::text as subtotal
from public.tenant_invoice_items
where invoice_id = $1
`,
[invoiceId],
);
const paymentResult = await client.query<{ paid: string }>(
`
select coalesce(sum(amount_cents), 0)::text as paid
from public.tenant_invoice_payments
where invoice_id = $1 and status = 'paid'
`,
[invoiceId],
);
const subtotal = Number(itemResult.rows[0]?.subtotal || 0);
const paid = Number(paymentResult.rows[0]?.paid || 0);
const updateResult = await client.query(
`
update public.tenant_invoices
set subtotal_cents = $2,
total_cents = greatest(0, $2 - discount_cents + tax_cents),
paid_cents = $3,
balance_cents = greatest(0, greatest(0, $2 - discount_cents + tax_cents) - $3),
status = case
when status = 'void' then status
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid'
when status = 'draft' then 'issued'
else status
end,
paid_at = case
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then coalesce(paid_at, now())
else paid_at
end,
updated_at = now()
where id = $1
returning id, invoice_no as "invoiceNo", status, subtotal_cents as "subtotalCents",
discount_cents as "discountCents", tax_cents as "taxCents",
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", paid_at as "paidAt"
`,
[invoiceId, subtotal, paid],
);
return updateResult.rows[0];
}

View File

@@ -0,0 +1,7 @@
import type { RouteDefinition } from '../../core/router.js';
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
export const profileRoutes: RouteDefinition[] = [
['GET', '/api/profile/me', profileMeRoute],
['PATCH', '/api/profile/me', updateProfileMeRoute],
];

View File

@@ -0,0 +1,259 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
type JsonMap = Record<string, unknown>;
interface ProfileRow {
id: string;
userId: string;
username: string | null;
phone: string | null;
email: string | null;
name: string | null;
avatarUrl: string | null;
primaryRole: string;
score: number;
regionId: string | null;
regionName: string | null;
selectedSchoolId: string | null;
selectedSchoolName: string | null;
selectedMajorId: string | null;
selectedMajorName: string | null;
questionsAnsweredToday: number;
masteredWordsCount: number;
lastCheckInDate: string | null;
stats: JsonMap;
progress: JsonMap;
moduleSelections: JsonMap;
recentActivities: unknown[];
createdAt: string;
updatedAt: string;
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' ? value : {});
}
function jsonArrayBodyValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'recentLimit', 8, 50);
const profile = await queryOne<ProfileRow>(
`
select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name,
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score,
sp.region_id as "regionId", r.name as "regionName",
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
sp.questions_answered_today as "questionsAnsweredToday",
sp.mastered_words_count as "masteredWordsCount",
sp.last_check_in_date as "lastCheckInDate",
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
sp.recent_activities as "recentActivities",
sp.created_at as "createdAt", sp.updated_at as "updatedAt"
from public.student_profiles sp
join public.platform_users u on u.id = sp.user_id
left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id
left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id
left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id
where sp.tenant_id = $1 and sp.user_id = $2
limit 1
`,
[tenantId, userId],
);
if (!profile) {
throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
}
const recentPractices = await query(
`
select id, practice_type as "practiceType", target_legacy_id as "targetLegacyId",
target_name as "targetName", progress, color,
last_access_at as "lastAccessAt", last_practice_at as "lastPracticeAt",
metadata, created_at as "createdAt", updated_at as "updatedAt"
from public.recent_practices
where tenant_id = $1 and user_id = $2
order by last_practice_at desc nulls last, last_access_at desc nulls last, updated_at desc
limit $3
`,
[tenantId, userId, limit],
);
const answerStats = await queryOne<{
totalAnswered: string;
correctCount: string;
wrongCount: string;
latestAnsweredAt: string | null;
}>(
`
select count(*)::text as "totalAnswered",
count(*) filter (where is_correct is true)::text as "correctCount",
count(*) filter (where is_correct is false)::text as "wrongCount",
max(answered_at) as "latestAnsweredAt"
from public.answer_records
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
const wordStats = await queryOne<{
totalWords: string;
progressedWords: string;
masteredWords: string;
learningWords: string;
favoriteWords: string;
}>(
`
select
(select count(*) from public.vocabulary_words where tenant_id = $1 and is_active = true)::text as "totalWords",
count(*)::text as "progressedWords",
count(*) filter (where status = 'mastered')::text as "masteredWords",
count(*) filter (where status in ('learning', 'reviewing'))::text as "learningWords",
(select count(*) from public.user_word_favorites where tenant_id = $1 and user_id = $2)::text as "favoriteWords"
from public.user_word_progress
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
const entitlement = await queryOne(
`
select id, entitlement_type as "entitlementType", scope_type as "scopeType",
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
status, metadata
from public.entitlements
where tenant_id = $1 and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= now()
and (expires_at is null or expires_at > now())
order by expires_at desc nulls first, created_at desc
limit 1
`,
[tenantId, userId],
);
const orderSummary = await queryOne<{
totalOrders: string;
paidOrders: string;
paidAmountCents: string;
}>(
`
select count(*)::text as "totalOrders",
count(*) filter (where status = 'paid')::text as "paidOrders",
coalesce(sum(amount_cents) filter (where status = 'paid'), 0)::text as "paidAmountCents"
from public.orders
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return {
item: {
...profile,
target: {
regionId: profile.regionId,
regionName: profile.regionName,
schoolId: profile.selectedSchoolId,
schoolName: profile.selectedSchoolName,
majorId: profile.selectedMajorId,
majorName: profile.selectedMajorName,
},
membership: {
isSvip: !!entitlement,
entitlement,
},
stats: {
...profile.stats,
answers: {
totalAnswered: Number(answerStats?.totalAnswered || 0),
correctCount: Number(answerStats?.correctCount || 0),
wrongCount: Number(answerStats?.wrongCount || 0),
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
},
vocabulary: {
totalWords: Number(wordStats?.totalWords || 0),
progressedWords: Number(wordStats?.progressedWords || 0),
masteredWords: Number(wordStats?.masteredWords || 0),
learningWords: Number(wordStats?.learningWords || 0),
favoriteWords: Number(wordStats?.favoriteWords || 0),
},
orders: {
totalOrders: Number(orderSummary?.totalOrders || 0),
paidOrders: Number(orderSummary?.paidOrders || 0),
paidAmountCents: Number(orderSummary?.paidAmountCents || 0),
},
},
recentPractices,
},
};
}
export async function updateProfileMeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const name = optionalString(body, 'name') || null;
const avatarUrl = optionalString(body, 'avatarUrl') || null;
const regionId = optionalString(body, 'regionId') || null;
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
const item = await queryOne(
`
with updated_user as (
update public.platform_users
set name = coalesce($3, name),
avatar_url = coalesce($4, avatar_url),
updated_at = now()
where id = $2
returning id
)
insert into public.student_profiles (
tenant_id, user_id, region_id, selected_school_id, selected_major_id,
stats, progress, module_selections, recent_activities
)
values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
on conflict (tenant_id, user_id)
do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id),
selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id),
stats = case when $12::boolean then excluded.stats else public.student_profiles.stats end,
progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end,
module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end,
recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
updated_at = now()
returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId",
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId",
stats, progress, module_selections as "moduleSelections",
recent_activities as "recentActivities", updated_at as "updatedAt"
`,
[
tenantId,
userId,
name,
avatarUrl,
regionId,
selectedSchoolId,
selectedMajorId,
jsonBodyValue(body.stats),
jsonBodyValue(body.progress),
jsonBodyValue(body.moduleSelections),
jsonArrayBodyValue(body.recentActivities),
Object.hasOwn(body, 'stats'),
Object.hasOwn(body, 'progress'),
Object.hasOwn(body, 'moduleSelections'),
Object.hasOwn(body, 'recentActivities'),
],
);
return { item };
}

View File

@@ -0,0 +1,34 @@
import type { RouteDefinition } from '../../core/router.js';
import {
crmConfigRoute,
crmQueueRoute,
referralBindRoute,
referralInviteCodeRoute,
referralManualBindRoute,
referralQrcodeRoute,
referralResolveRoute,
referralSalesClientsRoute,
referralSalesStatsRoute,
referralStatsRoute,
referralTeamRoute,
referralTrackEventRoute,
upsertCrmConfigRoute,
upsertReferralTeamRoute,
} from './routes.js';
export const referralRoutes: RouteDefinition[] = [
['POST', '/api/referral/invite-code', referralInviteCodeRoute],
['POST', '/api/referral/resolve', referralResolveRoute],
['POST', '/api/referral/track-event', referralTrackEventRoute],
['POST', '/api/referral/bind', referralBindRoute],
['GET', '/api/referral/stats', referralStatsRoute],
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
['POST', '/api/referral/manual-bind', referralManualBindRoute],
['GET', '/api/referral/team', referralTeamRoute],
['PUT', '/api/referral/team', upsertReferralTeamRoute],
['POST', '/api/referral/qrcode', referralQrcodeRoute],
['GET', '/api/crm/config', crmConfigRoute],
['PUT', '/api/crm/config', upsertCrmConfigRoute],
['GET', '/api/crm/queue', crmQueueRoute],
];

View File

@@ -0,0 +1,920 @@
import { randomBytes } from 'node:crypto';
import type pg from 'pg';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
type JsonBody = Record<string, unknown>;
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function normalizeCode(code: string) {
return code.replace(/\s+/g, '').toUpperCase();
}
function generateReferralCode() {
let code = '';
for (let index = 0; index < 6; index += 1) {
code += REFERRAL_CODE_ALPHABET[randomBytes(1)[0] % REFERRAL_CODE_ALPHABET.length];
}
return code;
}
function centsToAmount(cents: number) {
return Math.round(cents) / 100;
}
function canViewAllReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:read');
}
function canViewSelfReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
}
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query(
`
select 1
from public.tenant_memberships
where tenant_id = $1 and user_id = $2 and status = 'active'
limit 1
`,
[tenantId, userId],
);
return Boolean(result.rows[0]);
}
async function resolveReferralCode(tenantId: string, code: string) {
return queryOne<{
code: string;
userId: string;
status: string;
username: string | null;
name: string | null;
phone: string | null;
role: string | null;
}>(
`
select rc.code::text as code, rc.user_id as "userId", rc.status,
u.username, u.name, u.phone, tm.role
from public.referral_codes rc
join public.platform_users u on u.id = rc.user_id
left join lateral (
select role
from public.tenant_memberships
where tenant_id = rc.tenant_id and user_id = rc.user_id and status = 'active'
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'sales' then 3
when 'agent' then 4
when 'teacher' then 5
else 9
end
limit 1
) tm on true
where rc.tenant_id = $1
and rc.code = $2::citext
and rc.status = 'active'
limit 1
`,
[tenantId, normalizeCode(code)],
);
}
async function createOrGetReferralCode(client: pg.PoolClient, tenantId: string, userId: string, metadata: Record<string, unknown> = {}) {
const existing = await client.query<{ code: string }>(
`
select code::text
from public.referral_codes
where tenant_id = $1 and user_id = $2
limit 1
`,
[tenantId, userId],
);
if (existing.rows[0]?.code) return existing.rows[0].code;
for (let attempt = 0; attempt < 30; attempt += 1) {
const candidate = generateReferralCode();
const inserted = await client.query<{ code: string }>(
`
insert into public.referral_codes (tenant_id, user_id, code, metadata)
values ($1, $2, $3::citext, $4::jsonb)
on conflict do nothing
returning code::text
`,
[tenantId, userId, candidate, JSON.stringify(metadata)],
);
if (inserted.rows[0]?.code) return inserted.rows[0].code;
}
throw new HttpError(500, 'Failed to generate referral code', 'REFERRAL_CODE_GENERATION_FAILED');
}
async function enqueueCrmLead(
client: pg.PoolClient,
input: {
tenantId: string;
leadId: string;
studentUserId: string;
source: string;
metadata?: Record<string, unknown>;
},
) {
const cfg = await client.query<{
enabled: boolean;
url: string | null;
form_name: string | null;
exam_type: string | null;
delay_sec: number | null;
timeout_sec: number | null;
}>(
`
select enabled, url, form_name, exam_type, delay_sec, timeout_sec
from public.crm_config
where tenant_id = $1
limit 1
`,
[input.tenantId],
);
const config = cfg.rows[0];
if (!config?.enabled || !config.url) return null;
const student = await client.query<{
id: string;
username: string | null;
name: string | null;
phone: string | null;
email: string | null;
raw_profile: Record<string, unknown>;
}>(
`
select id, username, name, phone, email::text, raw_profile
from public.platform_users
where id = $1
limit 1
`,
[input.studentUserId],
);
const lead = await client.query<{
ref_code: string | null;
referrer_user_id: string | null;
bound_at: string;
}>(
`
select ref_code::text, referrer_user_id, bound_at
from public.referral_leads
where tenant_id = $1 and id = $2
limit 1
`,
[input.tenantId, input.leadId],
);
const payload = {
formName: config.form_name || '刷题题库',
examType: config.exam_type || '成人本科',
source: input.source,
leadId: input.leadId,
student: {
id: input.studentUserId,
username: student.rows[0]?.username || null,
name: student.rows[0]?.name || null,
phone: student.rows[0]?.phone || null,
email: student.rows[0]?.email || null,
},
referral: {
refCode: lead.rows[0]?.ref_code || null,
referrerUserId: lead.rows[0]?.referrer_user_id || null,
boundAt: lead.rows[0]?.bound_at || null,
},
metadata: input.metadata || {},
};
const delaySeconds = Math.max(0, Number(config.delay_sec || 60));
const idempotencyKey = `lead:${input.leadId}`;
const queued = await client.query(
`
insert into public.crm_webhook_queue (
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
source, payload, idempotency_key, target_url
)
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, $4, $5, $6::jsonb, $7, $8)
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
do update set status = case
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
else 'pending'
end,
scheduled_at = excluded.scheduled_at,
next_attempt_at = null,
payload = excluded.payload,
target_url = excluded.target_url,
updated_at = now()
returning id, status, scheduled_at as "scheduledAt", lead_id as "leadId"
`,
[
input.tenantId,
input.studentUserId,
delaySeconds,
input.leadId,
input.source,
JSON.stringify(payload),
idempotencyKey,
config.url,
],
);
return queued.rows[0];
}
async function bindReferralLead(
client: pg.PoolClient,
input: {
tenantId: string;
studentUserId: string;
refCode: string;
referrerUserId: string | null;
source: string;
trackId: string | null;
bindType?: 'first_touch' | 'manual' | 'imported';
force?: boolean;
metadata?: Record<string, unknown>;
},
) {
if (input.referrerUserId && input.referrerUserId === input.studentUserId) {
throw new HttpError(400, 'Cannot bind a lead to itself', 'SELF_REFERRAL_NOT_ALLOWED');
}
const existing = await client.query<{
id: string;
referrerUserId: string | null;
refCode: string | null;
status: string;
boundAt: string;
}>(
`
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
status, bound_at as "boundAt"
from public.referral_leads
where tenant_id = $1 and student_user_id = $2
limit 1
`,
[input.tenantId, input.studentUserId],
);
if (existing.rows[0] && !input.force) {
return { item: existing.rows[0], bound: false, protected: true };
}
const result = await client.query(
`
insert into public.referral_leads (
tenant_id, student_user_id, referrer_user_id, ref_code, source,
first_track_id, bind_type, status, metadata
)
values ($1, $2, $3, $4::citext, $5, $6::uuid, $7, 'protected', $8::jsonb)
on conflict (tenant_id, student_user_id)
do update set referrer_user_id = excluded.referrer_user_id,
ref_code = excluded.ref_code,
source = excluded.source,
first_track_id = coalesce(public.referral_leads.first_track_id, excluded.first_track_id),
bind_type = excluded.bind_type,
status = 'protected',
metadata = public.referral_leads.metadata || excluded.metadata,
updated_at = now()
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
ref_code::text as "refCode", source, bind_type as "bindType", status,
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
`,
[
input.tenantId,
input.studentUserId,
input.referrerUserId,
normalizeCode(input.refCode),
input.source,
input.trackId,
input.bindType || 'first_touch',
JSON.stringify(input.metadata || {}),
],
);
if (input.trackId) {
await client.query(
'update public.referral_tracks set lead_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
[input.tenantId, input.trackId, result.rows[0].id],
);
}
return { item: result.rows[0], bound: true, protected: false };
}
function crmPermission(auth: TenantAdminAuth) {
requireTenantPermission(auth, 'crm:read');
}
export async function referralInviteCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const item = await transaction(async client => {
const activeMember = await userHasTenantMembership(client, tenantId, userId);
if (!activeMember) {
throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
}
const code = await createOrGetReferralCode(client, tenantId, userId, { source: 'api' });
return { inviteCode: code };
});
return item;
}
export async function referralResolveRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = optionalString(body, 'code');
if (!code) return { valid: false };
const item = await resolveReferralCode(tenantId, code);
if (!item) return { valid: false };
return {
valid: true,
inviterId: item.userId,
inviteCode: item.code,
role: item.role,
name: item.name || item.username || '推广员',
};
}
export async function referralTrackEventRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
const refCode = normalizeCode(requiredString(body, 'refCode'));
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
const referrer = await resolveReferralCode(tenantId, refCode);
const result = await transaction(async client => {
const track = await client.query(
`
insert into public.referral_tracks (
tenant_id, event_type, ref_code, ref_user_id, target_user_id, source,
ip_address, user_agent, metadata
)
values ($1, $2, $3::citext, $4, $5, $6, $7, $8, $9::jsonb)
returning id, event_type as "eventType", ref_code::text as "refCode",
ref_user_id as "refUserId", target_user_id as "targetUserId",
source, created_at as "createdAt"
`,
[
tenantId,
eventType,
refCode,
referrer?.userId || null,
targetUserId,
source,
clientIpFrom(ctx),
userAgentFrom(ctx),
JSON.stringify(objectValue(body.metadata)),
],
);
let lead = null;
let crmQueue = null;
if (targetUserId && referrer?.userId && (eventType === 'register' || eventType === 'enter' || eventType === 'scan')) {
lead = await bindReferralLead(client, {
tenantId,
studentUserId: targetUserId,
refCode,
referrerUserId: referrer.userId,
source,
trackId: track.rows[0].id,
metadata: { eventType },
});
if (lead.bound) {
crmQueue = await enqueueCrmLead(client, {
tenantId,
leadId: lead.item.id,
studentUserId: targetUserId,
source,
metadata: { trigger: 'referral.track_event', eventType },
});
}
}
return { track: track.rows[0], lead, crmQueue };
});
return { item: result.track, lead: result.lead, crmQueue: result.crmQueue };
}
export async function referralBindRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const studentUserId = userIdFrom(ctx, body);
const refCode = normalizeCode(requiredString(body, 'refCode'));
const referrer = await resolveReferralCode(tenantId, refCode);
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
const result = await transaction(async client => {
const lead = await bindReferralLead(client, {
tenantId,
studentUserId,
refCode,
referrerUserId: referrer.userId,
source: optionalString(body, 'source') || 'manual',
trackId: null,
metadata: objectValue(body.metadata),
});
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
tenantId,
leadId: lead.item.id,
studentUserId,
source: optionalString(body, 'source') || 'manual',
metadata: { trigger: 'referral.bind' },
}) : null;
return { lead, crmQueue };
});
return result;
}
export async function referralStatsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
const item = await queryOne<{
referrerUserId: string;
leadCount: string;
paidLeadCount: string;
paidAmountCents: string;
trackCount: string;
}>(
`
with leads as (
select rl.student_user_id
from public.referral_leads rl
where rl.tenant_id = $1 and rl.referrer_user_id = $2 and rl.status = 'protected'
),
paid as (
select o.user_id, sum(o.amount_cents)::bigint as amount_cents
from public.orders o
join leads l on l.student_user_id = o.user_id
where o.tenant_id = $1 and o.status = 'paid'
group by o.user_id
)
select $2::uuid as "referrerUserId",
(select count(*) from leads)::text as "leadCount",
(select count(*) from paid)::text as "paidLeadCount",
coalesce((select sum(amount_cents) from paid), 0)::text as "paidAmountCents",
(
select count(*)
from public.referral_tracks rt
where rt.tenant_id = $1 and rt.ref_user_id = $2
)::text as "trackCount"
`,
[auth.tenantId, referrerUserId],
);
const paidAmountCents = Number(item?.paidAmountCents || 0);
return {
item: {
referrerUserId,
leadCount: Number(item?.leadCount || 0),
paidLeadCount: Number(item?.paidLeadCount || 0),
paidAmountCents,
paidAmount: centsToAmount(paidAmountCents),
trackCount: Number(item?.trackCount || 0),
conversionRate: Number(item?.leadCount || 0) > 0
? Math.round((Number(item?.paidLeadCount || 0) / Number(item?.leadCount || 0)) * 100)
: 0,
},
};
}
export async function referralSalesStatsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
let scopeSql = '';
if (!canViewAllReferral(auth)) {
params.push(auth.userId);
scopeSql = `and tm.user_id = $${params.length}`;
}
params.push(limit);
const items = await query<{
referrerUserId: string;
role: string;
username: string | null;
name: string | null;
phone: string | null;
inviteCode: string | null;
leadCount: number;
paidLeadCount: number;
paidAmountCents: string;
trackCount: number;
}>(
`
with referrers as (
select tm.user_id, tm.role, u.username, u.name, u.phone,
rc.code::text as code
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
where tm.tenant_id = $1
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
${scopeSql}
),
lead_stats as (
select referrer_user_id,
count(*)::int as lead_count,
count(*) filter (where exists (
select 1 from public.orders o
where o.tenant_id = rl.tenant_id
and o.user_id = rl.student_user_id
and o.status = 'paid'
))::int as paid_lead_count
from public.referral_leads rl
where rl.tenant_id = $1 and rl.status = 'protected'
group by referrer_user_id
),
order_stats as (
select rl.referrer_user_id, coalesce(sum(o.amount_cents), 0)::bigint as paid_amount_cents
from public.referral_leads rl
join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id and o.status = 'paid'
where rl.tenant_id = $1 and rl.status = 'protected'
group by rl.referrer_user_id
),
track_stats as (
select ref_user_id, count(*)::int as track_count
from public.referral_tracks
where tenant_id = $1
group by ref_user_id
)
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
r.code as "inviteCode",
coalesce(ls.lead_count, 0) as "leadCount",
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
coalesce(ts.track_count, 0) as "trackCount"
from referrers r
left join lead_stats ls on ls.referrer_user_id = r.user_id
left join order_stats os on os.referrer_user_id = r.user_id
left join track_stats ts on ts.ref_user_id = r.user_id
order by coalesce(ls.lead_count, 0) desc, r.name asc nulls last
limit $${params.length}
`,
params,
);
return {
items: items.map((item: Record<string, unknown>) => ({
...item,
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
conversionRate: Number(item.leadCount || 0) > 0
? Math.round((Number(item.paidLeadCount || 0) / Number(item.leadCount || 0)) * 100)
: 0,
})),
};
}
export async function referralSalesClientsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query<{
id: string;
studentUserId: string;
username: string | null;
name: string | null;
phone: string | null;
email: string | null;
referrerUserId: string;
refCode: string | null;
source: string | null;
bindType: string;
status: string;
boundAt: string;
paidAmountCents: string;
lastPaidAt: string | null;
}>(
`
select rl.id, rl.student_user_id as "studentUserId",
u.username, u.name, u.phone, u.email::text as email,
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt",
coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents",
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
from public.referral_leads rl
join public.platform_users u on u.id = rl.student_user_id
left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id
where rl.tenant_id = $1
and rl.referrer_user_id = $2
and rl.status = 'protected'
group by rl.id, u.id
order by rl.bound_at desc
limit $3
`,
[auth.tenantId, referrerUserId, limit],
);
return {
items: items.map((item: Record<string, unknown>) => ({
...item,
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
})),
};
}
export async function referralManualBindRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'referral:write');
const body = await readJsonBody(ctx);
const studentUserId = requiredString(body, 'studentUserId');
const referrerUserId = requiredString(body, 'referrerUserId');
const code = await transaction(async client => {
const member = await userHasTenantMembership(client, auth.tenantId, referrerUserId);
if (!member) throw new HttpError(400, 'Referrer is not an active tenant member', 'REFERRER_NOT_TENANT_MEMBER');
return createOrGetReferralCode(client, auth.tenantId, referrerUserId, { source: 'manual_bind' });
});
const result = await transaction(async client => {
const lead = await bindReferralLead(client, {
tenantId: auth.tenantId,
studentUserId,
refCode: code,
referrerUserId,
source: optionalString(body, 'source') || 'manual',
trackId: null,
bindType: 'manual',
force: body.force === true,
metadata: { operatorUserId: auth.userId, ...objectValue(body.metadata) },
});
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
tenantId: auth.tenantId,
leadId: lead.item.id,
studentUserId,
source: optionalString(body, 'source') || 'manual',
metadata: { trigger: 'referral.manual_bind', operatorUserId: auth.userId },
}) : null;
return { lead, crmQueue };
});
return result;
}
export async function referralTeamRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedLeaderId = stringParam(ctx, 'leaderUserId');
const leaderUserId = canViewAllReferral(auth) && requestedLeaderId ? requestedLeaderId : auth.userId;
const items = await query(
`
select rte.id, rte.member_user_id as "memberUserId", rte.leader_user_id as "leaderUserId",
rte.relation_type as "relationType", rte.status, rte.metadata,
u.username, u.name, u.phone,
tm.role
from public.referral_team_edges rte
join public.platform_users u on u.id = rte.member_user_id
left join public.tenant_memberships tm on tm.tenant_id = rte.tenant_id
and tm.user_id = rte.member_user_id
and tm.status = 'active'
where rte.tenant_id = $1
and rte.leader_user_id = $2
and rte.status = 'active'
order by rte.created_at asc
`,
[auth.tenantId, leaderUserId],
);
return { items };
}
export async function upsertReferralTeamRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'referral:write');
const body = await readJsonBody(ctx);
const memberUserId = requiredString(body, 'memberUserId');
const leaderUserId = nullableString(body.leaderUserId);
const relationType = optionalChoice(body.relationType, ['sales_team', 'agent_network', 'teacher_class'], 'sales_team');
const status = optionalChoice(body.status, ['active', 'disabled'], 'active');
const item = await transaction(async client => {
const member = await userHasTenantMembership(client, auth.tenantId, memberUserId);
if (!member) throw new HttpError(400, 'Member is not an active tenant member', 'MEMBER_NOT_TENANT_MEMBER');
if (leaderUserId) {
const leader = await userHasTenantMembership(client, auth.tenantId, leaderUserId);
if (!leader) throw new HttpError(400, 'Leader is not an active tenant member', 'LEADER_NOT_TENANT_MEMBER');
if (leaderUserId === memberUserId) throw new HttpError(400, 'Leader cannot be the member itself', 'SELF_LEADER_NOT_ALLOWED');
}
const result = await client.query(
`
insert into public.referral_team_edges (
tenant_id, member_user_id, leader_user_id, relation_type, status, metadata
)
values ($1, $2, $3, $4, $5, $6::jsonb)
on conflict (tenant_id, member_user_id, relation_type)
do update set leader_user_id = excluded.leader_user_id,
status = excluded.status,
metadata = excluded.metadata,
updated_at = now()
returning id, member_user_id as "memberUserId", leader_user_id as "leaderUserId",
relation_type as "relationType", status, metadata, updated_at as "updatedAt"
`,
[auth.tenantId, memberUserId, leaderUserId, relationType, status, JSON.stringify(objectValue(body.metadata))],
);
return result.rows[0];
});
return { item };
}
export async function referralQrcodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const body = await readJsonBody(ctx);
const page = optionalString(body, 'page') || 'pages/index/index';
const provider = optionalString(body, 'provider') || 'wechat-miniapp';
const item = await transaction(async client => {
const activeMember = await userHasTenantMembership(client, tenantId, userId);
if (!activeMember) throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
const refCode = await createOrGetReferralCode(client, tenantId, userId, { source: 'qrcode' });
const scene = optionalString(body, 'scene') || `ref_${refCode}`;
const result = await client.query(
`
insert into public.referral_qrcodes (tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status, metadata)
values ($1, $2, $3::citext, $4, $5, $6, $7, 'ready', $8::jsonb)
on conflict (tenant_id, provider, scene, page)
do update set user_id = excluded.user_id,
ref_code = excluded.ref_code,
qrcode_url = coalesce(public.referral_qrcodes.qrcode_url, excluded.qrcode_url),
status = 'ready',
metadata = public.referral_qrcodes.metadata || excluded.metadata,
updated_at = now()
returning id, ref_code::text as "refCode", scene, page, provider,
qrcode_url as "qrcodeUrl", status, created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
refCode,
scene,
page,
provider,
optionalString(body, 'qrcodeUrl') || `miniapp://${page}?scene=${encodeURIComponent(scene)}`,
JSON.stringify({ generatedBy: 'local-placeholder', ...objectValue(body.metadata) }),
],
);
return result.rows[0];
});
return { item };
}
export async function crmConfigRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
crmPermission(auth);
const item = await queryOne(
`
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
created_at as "createdAt", updated_at as "updatedAt"
from public.crm_config
where tenant_id = $1
limit 1
`,
[auth.tenantId],
);
return { item };
}
export async function upsertCrmConfigRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'crm:write');
const body = await readJsonBody(ctx);
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
const item = await transaction(async client => {
if (body.secret && typeof body.secret === 'string') {
await client.query(
`
insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ($1, 'crm', 'webhook', $2, 'webhook', now())
on conflict (tenant_id, secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[auth.tenantId, body.secret],
);
}
const result = await client.query(
`
insert into public.crm_config (
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
)
values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (tenant_id)
do update set enabled = excluded.enabled,
url = excluded.url,
secret_ref = excluded.secret_ref,
form_name = excluded.form_name,
exam_type = excluded.exam_type,
timeout_sec = excluded.timeout_sec,
delay_sec = excluded.delay_sec,
updated_at = now()
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
updated_at as "updatedAt"
`,
[
auth.tenantId,
body.enabled === true,
nullableString(body.url),
secretRef,
optionalString(body, 'formName') || '刷题题库',
optionalString(body, 'examType') || '成人本科',
Number(body.timeoutSec || 10),
Number(body.delaySec || 60),
],
);
return result.rows[0];
});
return { item };
}
export async function crmQueueRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
crmPermission(auth);
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, record_id as "recordId", status, scheduled_at as "scheduledAt",
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
payload, created_at as "createdAt", updated_at as "updatedAt"
from public.crm_webhook_queue
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
scorelineFieldsRoute,
scorelineMajorsRoute,
scorelineRecordsRoute,
scorelineSchoolsRoute,
scorelineTrendRoute,
scorelineYearsRoute,
} from './routes.js';
export const scorelineRoutes: RouteDefinition[] = [
['GET', '/api/scoreline/fields', scorelineFieldsRoute],
['GET', '/api/scoreline/schools', scorelineSchoolsRoute],
['GET', '/api/scoreline/majors', scorelineMajorsRoute],
['GET', '/api/scoreline/records', scorelineRecordsRoute],
['GET', '/api/scoreline/trend', scorelineTrendRoute],
['GET', '/api/scoreline/years', scorelineYearsRoute],
];

View File

@@ -0,0 +1,164 @@
import type { RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, field_name asc
`,
[tenantId, regionId || null],
);
return { items };
}
export async function scorelineSchoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const q = stringParam(ctx, 'q');
const limit = intParam(ctx, 'limit', 200, 1000);
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, short_name as "shortName", type, is_hot as "isHot",
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_schools
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::text = '' or name ilike '%' || $3 || '%' or short_name ilike '%' || $3 || '%')
order by is_hot desc, sort_order asc, name asc, type asc nulls last
limit $4
`,
[tenantId, regionId || null, q, limit],
);
return { items };
}
export async function scorelineMajorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", name, sort_order as "order",
has_restriction as "hasRestriction", restriction_desc as "restrictionDesc",
created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_majors
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
order by sort_order asc, name asc
limit $4
`,
[tenantId, regionId || null, schoolId || null, limit],
);
return { items };
}
export async function scorelineRecordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
const year = Number(ctx.url.searchParams.get('year') || 0);
const page = intParam(ctx, 'page', 1, 10_000);
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
const offset = (page - 1) * pageSize;
const where = `
tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
and ($5::integer = 0 or year = $5::integer)
`;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const [countRow, items] = await Promise.all([
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", major_id as "majorId", year,
school_name as "schoolName", major_name as "majorName",
field_values as "fieldValues", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_records
where ${where}
order by year desc, school_name asc nulls last, major_name asc nulls last
limit $6 offset $7
`,
[...params, pageSize, offset],
),
]);
return {
page,
pageSize,
total: Number(countRow?.total || 0),
items,
};
}
export async function scorelineTrendRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
const limit = intParam(ctx, 'limit', 20, 100);
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues"
from public.scoreline_records
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
order by year asc, major_name asc nulls last
limit $5
`,
[tenantId, regionId || null, schoolId || null, majorId || null, limit],
);
return { items };
}
export async function scorelineYearsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query<{ year: number }>(
`
select distinct year
from public.scoreline_records
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by year desc
`,
[tenantId, regionId || null],
);
return { items: items.map(item => item.year) };
}

View File

@@ -0,0 +1,130 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
const TENANT_ADMIN_ROLES = new Set([
'tenant_owner',
'tenant_admin',
'tenant_operator',
'teacher',
'sales',
'agent',
]);
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
tenant_owner: ['*'],
tenant_admin: ['*'],
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
teacher: ['content:*'],
sales: ['codes:*', 'coupons:*', 'referral:*'],
agent: ['codes:read', 'coupons:read', 'referral:self'],
student: [],
};
export interface TenantAdminAuth {
tenantId: string;
userId: string;
role: string;
permissions: Record<string, unknown>;
}
function permissionKeys(permission: string) {
const parts = permission.split(':').filter(Boolean);
const keys = [permission];
for (let i = parts.length - 1; i >= 1; i -= 1) {
keys.push(`${parts.slice(0, i).join(':')}:*`);
}
keys.push('*');
return keys;
}
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
for (const key of permissionKeys(permission)) {
const value = permissions[key];
if (typeof value === 'boolean') return value;
}
return null;
}
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
const explicit = explicitPermission(auth.permissions, permission);
if (explicit !== null) return explicit;
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
return defaults.some(defaultPermission => {
if (defaultPermission === '*') return true;
if (defaultPermission === permission) return true;
if (defaultPermission.endsWith(':*')) {
return permission.startsWith(defaultPermission.slice(0, -1));
}
return false;
});
}
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
if (!hasTenantPermission(auth, permission)) {
throw new HttpError(403, `Tenant permission is required: ${permission}`, 'TENANT_PERMISSION_REQUIRED');
}
}
export function tenantPermissionCatalog() {
return {
permissions: [
{ key: 'tenant:overview:read', label: '租户概览' },
{ key: 'tenant:branding:write', label: '品牌配置' },
{ key: 'tenant:settings:write', label: '公开设置' },
{ key: 'tenant:domains:read', label: '域名查看' },
{ key: 'tenant:domains:write', label: '域名管理' },
{ key: 'tenant:payment:read', label: '商户配置查看' },
{ key: 'tenant:payment:write', label: '商户配置管理' },
{ key: 'tenant:auth:read', label: '登录配置查看' },
{ key: 'tenant:auth:write', label: '登录配置管理' },
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
{ key: 'tenant:secrets:write', label: '密钥轮换' },
{ key: 'marketing:read', label: '活动内容查看' },
{ key: 'marketing:write', label: '活动内容管理' },
{ key: 'codes:read', label: '激活码查看' },
{ key: 'codes:write', label: '激活码管理' },
{ key: 'coupons:read', label: '优惠券查看' },
{ key: 'coupons:write', label: '优惠券管理' },
{ key: 'referral:read', label: '客资全局查看' },
{ key: 'referral:self', label: '本人客资查看' },
{ key: 'referral:write', label: '客资归属管理' },
{ key: 'crm:read', label: 'CRM 队列查看' },
{ key: 'crm:write', label: 'CRM 入队和重试' },
{ key: 'members:read', label: '成员查看' },
{ key: 'members:write', label: '成员管理' },
{ key: 'audit:read', label: '审计日志查看' },
{ key: 'content:*', label: '内容维护' },
],
roleDefaults: ROLE_PERMISSION_DEFAULTS,
};
}
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
else 9
end
limit 1
`,
[tenantId, userId, Array.from(TENANT_ADMIN_ROLES)],
);
if (!membership) {
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
}

View File

@@ -0,0 +1,64 @@
import type { RouteDefinition } from '../../core/router.js';
import {
activationCodesRoute,
announcementsAdminRoute,
auditLogsRoute,
authProvidersRoute,
bannersAdminRoute,
codeBatchesRoute,
couponsRoute,
createTenantDomainRoute,
disableTenantMemberRoute,
faqsAdminRoute,
generateActivationCodesRoute,
paymentAccountsRoute,
tenantDomainsRoute,
tenantMembersRoute,
tenantOverviewRoute,
tenantPermissionsRoute,
tenantSecretsRoute,
upsertTenantMemberRoute,
upsertActivationCodeRoute,
upsertAnnouncementRoute,
upsertAuthProviderRoute,
upsertBannerRoute,
upsertCodeBatchRoute,
upsertCouponRoute,
upsertFaqRoute,
updateTenantBrandingRoute,
updateTenantSettingsRoute,
upsertPaymentAccountRoute,
upsertTenantSecretRoute,
} from './routes.js';
export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
['PUT', '/api/tenant-admin/payment-accounts', upsertPaymentAccountRoute],
['GET', '/api/tenant-admin/auth-providers', authProvidersRoute],
['PUT', '/api/tenant-admin/auth-providers', upsertAuthProviderRoute],
['GET', '/api/tenant-admin/secrets', tenantSecretsRoute],
['PUT', '/api/tenant-admin/secrets', upsertTenantSecretRoute],
['GET', '/api/tenant-admin/banners', bannersAdminRoute],
['PUT', '/api/tenant-admin/banners', upsertBannerRoute],
['GET', '/api/tenant-admin/faqs', faqsAdminRoute],
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
['PUT', '/api/tenant-admin/activation-codes', upsertActivationCodeRoute],
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
['GET', '/api/tenant-admin/coupons', couponsRoute],
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
['GET', '/api/tenant-admin/members', tenantMembersRoute],
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
['GET', '/api/tenant-admin/audit-logs', auditLogsRoute],
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
import { randomUUID } from 'node:crypto';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
const ASSET_STATUSES = ['draft', 'active', 'archived'];
interface AssetRow {
id: string;
tenantId: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
visibility: string;
status: string;
}
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function nullableUuid(value: unknown) {
return nullableString(value);
}
function safeFileName(fileName: string) {
return fileName
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.slice(0, 160) || 'asset';
}
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
if (!id) return;
const row = await queryOne<{ id: string }>(
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!row) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
`,
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
);
}
export async function assetsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const status = stringParam(ctx, 'status');
const visibility = stringParam(ctx, 'visibility');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
if (visibility) {
params.push(visibility);
filters.push(`visibility = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertAssetRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
const bucket = nullableString(body.bucket);
const objectKey = nullableString(body.objectKey);
const cdnUrl = nullableString(body.cdnUrl);
const title = requiredString(body, 'title');
const regionId = nullableUuid(body.regionId);
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
}
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
const item = await queryOne(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
is_public, region_id, subject_id, category_id, node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
$25, $26::jsonb, $27::jsonb, $28, $28, $29
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
asset_key = excluded.asset_key,
asset_type = excluded.asset_type,
storage_provider = excluded.storage_provider,
bucket = excluded.bucket,
object_key = excluded.object_key,
title = excluded.title,
category = excluded.category,
description = excluded.description,
file_name = excluded.file_name,
cdn_url = excluded.cdn_url,
preview_url = excluded.preview_url,
mime_type = excluded.mime_type,
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
visibility = excluded.visibility,
is_public = excluded.is_public,
region_id = excluded.region_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
source = excluded.source,
updated_at = now()
where public.content_assets.tenant_id = excluded.tenant_id
returning id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.legacyId),
nullableString(body.assetKey),
assetType,
storageProvider,
bucket,
objectKey,
title,
nullableString(body.categoryLabel) || nullableString(body.category),
nullableString(body.description),
nullableString(body.fileName),
cdnUrl,
nullableString(body.previewUrl),
nullableString(body.mimeType),
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
nullableString(body.checksumSha256),
visibility,
visibility === 'public',
regionId,
subjectId,
categoryId,
nodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
nullableString(body.source) || 'manual',
],
);
if (!item) {
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
}
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
title,
assetType,
visibility,
status,
});
return { item };
}
export async function signAssetUploadRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const fileName = requiredString(body, 'fileName');
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
const bucket = nullableString(body.bucket) || 'tenant-assets';
const objectKey =
nullableString(body.objectKey) ||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
return {
upload: {
provider: storageProvider,
bucket,
objectKey,
method: 'PUT',
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
headers: {
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
},
expiresAt,
signatureMode: 'local-placeholder',
},
assetDraft: {
assetType,
storageProvider,
bucket,
objectKey,
fileName,
mimeType: nullableString(body.mimeType),
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
checksumSha256: nullableString(body.checksumSha256),
},
};
}
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
title, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", visibility, status
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, assetId],
);
return {
item: asset,
download: placeholderSignedUrl(asset, expiresInSec),
};
}

View File

@@ -0,0 +1,43 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
export interface TenantContentAuth {
tenantId: string;
userId: string;
role: string;
permissions: Record<string, unknown>;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
limit 1
`,
[tenantId, userId, Array.from(CONTENT_ROLES)],
);
if (!membership) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
}

View File

@@ -0,0 +1,951 @@
import { createHash } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, nullableString } from './utils.js';
type JsonObject = Record<string, unknown>;
interface ImportIssue {
rowNo: number;
severity: 'error' | 'warning';
code: string;
fieldPath: string | null;
message: string;
details?: JsonObject;
}
interface NormalizedQuestion {
legacyId: string | null;
type: string;
typeLabel: string | null;
content: string;
options: unknown[];
correctOptionIndex: number | null;
correctOptionIndices: number[];
answerText: string | null;
explanation: string | null;
difficulty: number;
tags: string[];
mediaUrl: string | null;
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
sourceHash: string;
}
interface PreviewResult {
job: {
id: string;
status: string;
totalCount: number;
validCount: number;
errorCount: number;
warningCount: number;
};
items: Array<{
rowNo: number;
status: 'valid' | 'invalid';
externalId: string | null;
normalized: NormalizedQuestion | null;
issues: ImportIssue[];
}>;
issues: ImportIssue[];
}
const OBJECTIVE_TYPES = new Set(['choice', 'multi', 'judge', 'image']);
const READING_TYPE = 'reading';
const KNOWN_TYPES = new Set([
...OBJECTIVE_TYPES,
READING_TYPE,
'text',
'terms',
'short_answer',
'composition',
'discuss',
'translation',
'case_analysis',
'brief_analysis',
'calculation',
'analysis_design',
'combination',
'solution',
]);
function objectValue(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function stringArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value.map(item => String(item).trim()).filter(Boolean);
}
function numberArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map(item => Number(item))
.filter(Number.isFinite)
.map(item => Math.trunc(item));
}
function parseQuestionItems(body: JsonObject) {
const source = body.items ?? body.questions ?? body.payload;
let parsed: unknown = source;
if (typeof source === 'string') {
try {
parsed = JSON.parse(source);
} catch {
throw new HttpError(400, 'payload must be a valid JSON array string', 'INVALID_IMPORT_PAYLOAD');
}
}
if (!Array.isArray(parsed)) {
throw new HttpError(400, 'items/questions/payload must be a JSON array', 'INVALID_IMPORT_PAYLOAD');
}
if (parsed.length === 0) {
throw new HttpError(400, 'Import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD');
}
if (parsed.length > 2000) {
throw new HttpError(400, 'A single import job can contain at most 2000 items', 'IMPORT_TOO_LARGE');
}
return parsed;
}
function contentHash(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function normalizeDifficulty(value: unknown, issues: ImportIssue[], rowNo: number) {
if (value === undefined || value === null || value === '') return 1;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
issues.push({
rowNo,
severity: 'error',
code: 'INVALID_DIFFICULTY',
fieldPath: 'difficulty',
message: 'difficulty must be a number from 1 to 5',
});
return 1;
}
const difficulty = Math.trunc(parsed);
if (difficulty < 1 || difficulty > 5) {
issues.push({
rowNo,
severity: 'warning',
code: 'DIFFICULTY_OUT_OF_RANGE',
fieldPath: 'difficulty',
message: 'difficulty is outside the recommended 1-5 range and was clamped',
details: { original: value },
});
}
return Math.min(5, Math.max(1, difficulty));
}
function normalizeOptions(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath = 'options') {
if (!Array.isArray(value)) return [];
const normalized = value.filter(item => {
if (typeof item === 'string') return item.trim() !== '';
return item && typeof item === 'object';
});
if (normalized.length !== value.length) {
issues.push({
rowNo,
severity: 'warning',
code: 'EMPTY_OPTIONS_REMOVED',
fieldPath,
message: 'empty options were removed during normalization',
});
}
return normalized;
}
function normalizeSubQuestions(value: unknown, issues: ImportIssue[], rowNo: number) {
if (!Array.isArray(value)) return [];
return value.map((raw, index) => {
const sub = objectValue(raw);
const fieldPrefix = `sub_questions[${index}]`;
const type = stringValue(sub.type) || 'choice';
const content = stringValue(sub.content);
const options = normalizeOptions(sub.options, issues, rowNo, `${fieldPrefix}.options`);
const correctOptionIndices = numberArrayValue(sub.correctOptionIndices ?? sub.correct_option_indices);
const answerText = stringValue(sub.answerText ?? sub.answer_text);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_CONTENT_REQUIRED',
fieldPath: `${fieldPrefix}.content`,
message: 'sub question content is required',
});
}
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo, fieldPrefix);
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_ANSWER_REQUIRED',
fieldPath: `${fieldPrefix}.answerText`,
message: 'subjective sub question requires answerText',
});
}
return {
type,
typeLabel: stringValue(sub.typeLabel ?? sub.type_label) || null,
content,
options,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(sub.explanation) || null,
};
});
}
function validateObjectiveAnswer(
options: unknown[],
correctOptionIndices: number[],
issues: ImportIssue[],
rowNo: number,
fieldPrefix = '',
) {
const prefix = fieldPrefix ? `${fieldPrefix}.` : '';
if (options.length < 2) {
issues.push({
rowNo,
severity: 'error',
code: 'OPTIONS_REQUIRED',
fieldPath: `${prefix}options`,
message: 'objective question requires at least two options',
});
}
if (correctOptionIndices.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_REQUIRED',
fieldPath: `${prefix}correctOptionIndices`,
message: 'objective question requires correctOptionIndices',
});
}
for (const index of correctOptionIndices) {
if (index < 0 || index >= options.length) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_OUT_OF_RANGE',
fieldPath: `${prefix}correctOptionIndices`,
message: 'correct option index is outside the options range',
details: { index, optionsCount: options.length },
});
}
}
}
function normalizeQuestion(raw: unknown, rowNo: number) {
const issues: ImportIssue[] = [];
const source = objectValue(raw);
if (!source || Object.keys(source).length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'ROW_OBJECT_REQUIRED',
fieldPath: null,
message: 'each import row must be an object',
});
return { normalized: null, issues };
}
const subQuestions = normalizeSubQuestions(source.sub_questions ?? source.subQuestions, issues, rowNo);
let type = stringValue(source.type) || (subQuestions.length ? READING_TYPE : 'choice');
if (subQuestions.length) type = READING_TYPE;
if (!KNOWN_TYPES.has(type)) {
issues.push({
rowNo,
severity: 'warning',
code: 'UNKNOWN_QUESTION_TYPE',
fieldPath: 'type',
message: 'unknown question type was kept for compatibility',
details: { type },
});
}
const content = stringValue(source.content);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'CONTENT_REQUIRED',
fieldPath: 'content',
message: 'question content is required',
});
}
const options = normalizeOptions(source.options, issues, rowNo);
const legacyCorrectIndex = source.correctOptionIndex ?? source.correct_option_index;
const correctOptionIndices = numberArrayValue(source.correctOptionIndices ?? source.correct_option_indices);
if (correctOptionIndices.length === 0 && legacyCorrectIndex !== undefined && legacyCorrectIndex !== null && legacyCorrectIndex !== '') {
const parsed = Number(legacyCorrectIndex);
if (Number.isFinite(parsed)) correctOptionIndices.push(Math.trunc(parsed));
}
const answerText = stringValue(source.answerText ?? source.answer_text);
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo);
} else if (type === READING_TYPE) {
if (subQuestions.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTIONS_REQUIRED',
fieldPath: 'sub_questions',
message: 'reading question requires sub_questions',
});
}
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'ANSWER_TEXT_REQUIRED',
fieldPath: 'answerText',
message: 'subjective question requires answerText',
});
}
const normalizedWithoutHash = {
legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null,
type,
typeLabel: stringValue(source.typeLabel ?? source.type_label) || null,
content,
options,
correctOptionIndex: correctOptionIndices.length === 1 ? correctOptionIndices[0] : null,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(source.explanation) || null,
difficulty: normalizeDifficulty(source.difficulty, issues, rowNo),
tags: stringArrayValue(source.tags),
mediaUrl: stringValue(source.mediaUrl ?? source.media_url) || null,
subQuestions,
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
};
const normalized: NormalizedQuestion = {
...normalizedWithoutHash,
sourceHash: contentHash(normalizedWithoutHash),
};
return { normalized, issues };
}
async function assertTargetReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
const subjectId = requiredString(body, 'subjectId');
const categoryId = requiredString(body, 'categoryId');
const nodeId = nullableString(body.nodeId);
const questionBankId = nullableString(body.questionBankId);
const regionId = nullableString(body.regionId);
const subject = await client.query(
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, subjectId],
);
if (!subject.rows[0]) {
throw new HttpError(400, 'subjectId is not in this tenant', 'SUBJECT_NOT_FOUND');
}
const category = await client.query(
'select id from public.categories where tenant_id = $1 and id = $2 and subject_id = $3 limit 1',
[auth.tenantId, categoryId, subjectId],
);
if (!category.rows[0]) {
throw new HttpError(400, 'categoryId is not in this tenant or subject', 'CATEGORY_NOT_FOUND');
}
if (nodeId) {
const node = await client.query(
'select id from public.module_nodes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, nodeId],
);
if (!node.rows[0]) {
throw new HttpError(400, 'nodeId is not in this tenant', 'NODE_NOT_FOUND');
}
}
if (questionBankId) {
const questionBank = await client.query(
'select id from public.question_banks where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, questionBankId],
);
if (!questionBank.rows[0]) {
throw new HttpError(400, 'questionBankId is not in this tenant', 'QUESTION_BANK_NOT_FOUND');
}
}
if (regionId) {
const region = await client.query(
'select id from public.regions where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, regionId],
);
if (!region.rows[0]) {
throw new HttpError(400, 'regionId is not in this tenant', 'REGION_NOT_FOUND');
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId };
}
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
const rawItems = parseQuestionItems(body);
const sourceFormat = stringValue(body.sourceFormat) || 'json';
const sourceName = stringValue(body.sourceName) || null;
if (sourceFormat !== 'json') {
throw new HttpError(400, 'Only json sourceFormat is supported by the synchronous API for now', 'IMPORT_FORMAT_NOT_SUPPORTED');
}
return transaction(async client => {
const target = await assertTargetReferences(client, auth, body);
const normalizedItems = rawItems.map((raw, index) => {
const rowNo = index + 1;
const { normalized, issues } = normalizeQuestion(raw, rowNo);
return {
rowNo,
status: issues.some(issue => issue.severity === 'error') ? 'invalid' as const : 'valid' as const,
externalId: normalized?.legacyId || null,
source: raw,
normalized,
issues,
};
});
const issues = normalizedItems.flatMap(item => item.issues);
const errorCount = issues.filter(issue => issue.severity === 'error').length;
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
const validCount = normalizedItems.filter(item => item.status === 'valid').length;
const rawPayload = JSON.stringify(rawItems);
const normalizedPayload = JSON.stringify(normalizedItems.map(item => item.normalized).filter(Boolean));
const jobResult = await client.query(
`
insert into public.content_import_jobs (
tenant_id, created_by, import_type, source_format, status,
source_name, source_hash, target_region_id, target_subject_id,
target_category_id, target_node_id, target_question_bank_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload
)
values (
$1, $2, 'questions', $3, 'preview',
$4, $5, $6::uuid, $7::uuid,
$8::uuid, $9::uuid, $10::uuid,
true, $11, $12, $13, $14,
$15::jsonb, $16::jsonb, $17::jsonb
)
returning id, status, total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount"
`,
[
auth.tenantId,
auth.userId,
sourceFormat,
sourceName,
contentHash(rawItems),
target.regionId,
target.subjectId,
target.categoryId,
target.nodeId,
target.questionBankId,
rawItems.length,
validCount,
errorCount,
warningCount,
JSON.stringify({ target, generatedAt: new Date().toISOString() }),
rawPayload,
normalizedPayload,
],
);
const job = jobResult.rows[0];
const responseItems: PreviewResult['items'] = [];
for (const item of normalizedItems) {
const itemResult = await client.query(
`
insert into public.content_import_items (
tenant_id, job_id, row_no, external_id, status, target_type,
source_payload, normalized_payload, content_hash, issues_count
)
values ($1, $2, $3, $4, $5, 'question', $6::jsonb, $7::jsonb, $8, $9)
returning id
`,
[
auth.tenantId,
job.id,
item.rowNo,
item.externalId,
item.status,
JSON.stringify(item.source),
JSON.stringify(item.normalized || {}),
item.normalized?.sourceHash || contentHash(item.source),
item.issues.length,
],
);
const itemId = itemResult.rows[0].id;
for (const issue of item.issues) {
await client.query(
`
insert into public.content_import_issues (
tenant_id, job_id, item_id, row_no, severity, code,
field_path, message, details
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`,
[
auth.tenantId,
job.id,
itemId,
issue.rowNo,
issue.severity,
issue.code,
issue.fieldPath,
issue.message,
JSON.stringify(issue.details || {}),
],
);
}
responseItems.push({
rowNo: item.rowNo,
status: item.status,
externalId: item.externalId,
normalized: item.normalized,
issues: item.issues,
});
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.previewed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ importType: 'questions', total: rawItems.length, errorCount, warningCount })],
);
return {
job,
items: responseItems,
issues,
};
});
}
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
const result = await client.query<{
id: string;
status: string;
total_count: number;
valid_count: number;
error_count: number;
warning_count: number;
target_region_id: string | null;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
}>(
`
select id, status, total_count, valid_count, error_count, warning_count,
target_region_id, target_subject_id, target_category_id,
target_node_id, target_question_bank_id
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
for update
`,
[auth.tenantId, jobId],
);
const job = result.rows[0];
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
if (['importing', 'failed'].includes(job.status)) {
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
}
return job;
}
async function currentVersionHash(client: pg.PoolClient, questionId: string) {
const result = await client.query<{ source_hash: string | null }>(
`
select v.source_hash
from public.questions q
join public.question_versions v on v.id = q.current_version_id
where q.id = $1
limit 1
`,
[questionId],
);
return result.rows[0]?.source_hash || null;
}
async function importOneQuestion(
client: pg.PoolClient,
auth: TenantContentAuth,
job: {
id: string;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
},
item: {
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
},
) {
const normalized = item.normalized_payload;
const legacyId = normalized.legacyId || `content-import:${job.id}:${item.row_no}`;
const existing = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1 for update',
[auth.tenantId, legacyId],
);
const existingQuestion = existing.rows[0];
let questionId = existingQuestion?.id || '';
if (!existingQuestion) {
const inserted = await client.query<{ id: string }>(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
returning id
`,
[
auth.tenantId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
legacyId,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
questionId = inserted.rows[0].id;
} else {
await client.query(
`
update public.questions
set question_bank_id = $3::uuid,
subject_id = $4::uuid,
category_id = $5::uuid,
node_id = $6::uuid,
type = $7,
type_label = $8,
difficulty = $9,
tags = $10::jsonb,
media_url = $11,
status = 'published',
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
questionId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
}
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
if (previousHash && previousHash === normalized.sourceHash) {
await client.query(
`
update public.content_import_items
set status = 'skipped', target_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, questionId],
);
return 'skipped' as const;
}
const latest = await client.query<{ version_no: number }>(
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
[questionId],
);
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const version = await client.query<{ id: string }>(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
returning id
`,
[
auth.tenantId,
questionId,
nextVersionNo,
normalized.content,
JSON.stringify(normalized.options),
normalized.correctOptionIndex,
JSON.stringify(normalized.correctOptionIndices),
normalized.answerText,
normalized.explanation,
JSON.stringify(normalized.subQuestions),
normalized.codeLang,
normalized.codeTemplate,
normalized.sourceHash,
auth.userId,
],
);
await client.query(
'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, questionId, version.rows[0].id],
);
const status = existingQuestion ? 'updated' : 'inserted';
await client.query(
`
update public.content_import_items
set status = $3, target_id = $4, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, status, questionId],
);
return status as 'inserted' | 'updated';
}
export async function previewQuestionsImportRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
return createQuestionPreviewJob(auth, body);
}
export async function importQuestionsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const allowPartial = boolValue(body.allowPartial, false);
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
const createdPreview = jobId ? null : await createQuestionPreviewJob(auth, body);
const finalJobId = jobId || createdPreview?.job.id || '';
const result = await transaction(async client => {
const job = await loadPreviewJob(client, auth, finalJobId);
if (job.status === 'completed' || job.status === 'completed_with_errors') {
return {
jobId: job.id,
status: job.status,
idempotent: true,
insertedCount: 0,
updatedCount: 0,
skippedCount: 0,
};
}
if (job.error_count > 0 && !allowPartial) {
await client.query(
`
update public.content_import_jobs
set status = 'rejected', error_message = 'Preview contains validation errors', updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
}
await client.query(
`
update public.content_import_jobs
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
const itemResult = await client.query<{
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
}>(
`
select id, row_no, normalized_payload
from public.content_import_items
where tenant_id = $1 and job_id = $2 and status = 'valid'
order by row_no asc
for update
`,
[auth.tenantId, job.id],
);
let insertedCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const item of itemResult.rows) {
const status = await importOneQuestion(client, auth, job, item);
if (status === 'inserted') insertedCount += 1;
if (status === 'updated') updatedCount += 1;
if (status === 'skipped') skippedCount += 1;
}
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
await client.query(
`
update public.content_import_jobs
set status = $3,
inserted_count = $4,
updated_count = $5,
skipped_count = $6,
summary = coalesce(summary, '{}'::jsonb) || $7::jsonb,
finished_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
job.id,
finalStatus,
insertedCount,
updatedCount,
skippedCount,
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
],
);
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.questions.completed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ insertedCount, updatedCount, skippedCount, allowPartial })],
);
return {
jobId: job.id,
status: finalStatus,
insertedCount,
updatedCount,
skippedCount,
errorCount: job.error_count,
warningCount: job.warning_count,
};
});
return { item: result, preview: createdPreview };
}
export async function importJobsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const importType = stringParam(ctx, 'importType');
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (importType) {
params.push(importType);
filters.push(`import_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, import_type as "importType", source_format as "sourceFormat",
status, source_name as "sourceName", source_hash as "sourceHash",
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
target_question_bank_id as "targetQuestionBankId",
dry_run as "dryRun", total_count as "totalCount",
valid_count as "validCount", error_count as "errorCount",
warning_count as "warningCount", inserted_count as "insertedCount",
updated_count as "updatedCount", skipped_count as "skippedCount",
summary, error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
from public.content_import_jobs
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function importIssuesRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
const limit = intParam(ctx, 'limit', 500, 2000);
const job = await queryOne<{ id: string }>(
'select id from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, jobId],
);
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
const items = await query(
`
select i.id, i.row_no as "rowNo", i.severity, i.code,
i.field_path as "fieldPath", i.message, i.details,
item.external_id as "externalId", item.status as "itemStatus",
i.created_at as "createdAt"
from public.content_import_issues i
left join public.content_import_items item on item.id = i.item_id
where i.tenant_id = $1 and i.job_id = $2
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
limit $3
`,
[auth.tenantId, jobId, limit],
);
return { items };
}

View File

@@ -0,0 +1,72 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetsAdminRoute,
signAssetDownloadAdminRoute,
signAssetUploadRoute,
upsertAssetRoute,
} from './assets.js';
import {
importIssuesRoute,
importJobsRoute,
importQuestionsRoute,
previewQuestionsImportRoute,
} from './imports.js';
import {
bindQuestionVideoRoute,
createQuestionRoute,
handbookChaptersAdminRoute,
handbookEntriesAdminRoute,
handbookSubjectsAdminRoute,
scorelineFieldsAdminRoute,
scorelineMajorsAdminRoute,
scorelineRecordsAdminRoute,
scorelineSchoolsAdminRoute,
updateQuestionRoute,
upsertHandbookChapterRoute,
upsertHandbookEntryRoute,
upsertHandbookSubjectRoute,
upsertScorelineFieldRoute,
upsertScorelineMajorRoute,
upsertScorelineRecordRoute,
upsertScorelineSchoolRoute,
upsertVideoRoute,
upsertVocabularyUnitRoute,
upsertVocabularyWordRoute,
videosAdminRoute,
vocabularyUnitsAdminRoute,
vocabularyWordsAdminRoute,
} from './routes.js';
export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute],
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
['GET', '/api/tenant-content/scoreline/schools', scorelineSchoolsAdminRoute],
['PUT', '/api/tenant-content/scoreline/schools', upsertScorelineSchoolRoute],
['GET', '/api/tenant-content/scoreline/majors', scorelineMajorsAdminRoute],
['PUT', '/api/tenant-content/scoreline/majors', upsertScorelineMajorRoute],
['GET', '/api/tenant-content/scoreline/fields', scorelineFieldsAdminRoute],
['PUT', '/api/tenant-content/scoreline/fields', upsertScorelineFieldRoute],
['GET', '/api/tenant-content/scoreline/records', scorelineRecordsAdminRoute],
['PUT', '/api/tenant-content/scoreline/records', upsertScorelineRecordRoute],
['GET', '/api/tenant-content/vocabulary-units', vocabularyUnitsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-units', upsertVocabularyUnitRoute],
['GET', '/api/tenant-content/vocabulary-words', vocabularyWordsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-words', upsertVocabularyWordRoute],
['GET', '/api/tenant-content/handbook-subjects', handbookSubjectsAdminRoute],
['PUT', '/api/tenant-content/handbook-subjects', upsertHandbookSubjectRoute],
['GET', '/api/tenant-content/handbook-chapters', handbookChaptersAdminRoute],
['PUT', '/api/tenant-content/handbook-chapters', upsertHandbookChapterRoute],
['GET', '/api/tenant-content/handbook-entries', handbookEntriesAdminRoute],
['PUT', '/api/tenant-content/handbook-entries', upsertHandbookEntryRoute],
];

View File

@@ -0,0 +1,820 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor } from './auth.js';
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js';
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
export async function createQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const questionResult = await client.query(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
nullableString(body.legacyId),
optionalString(body, 'type') || 'choice',
optionalString(body, 'typeLabel') || null,
intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
],
);
const question = questionResult.rows[0];
const versionResult = await client.query(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13)
returning id, version_no as "versionNo", content, options,
correct_option_index as "correctOptionIndex",
correct_option_indices as "correctOptionIndices",
answer_text as "answerText", explanation, sub_questions as "subQuestions",
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
`,
[
auth.tenantId,
question.id,
nullableString(body.content),
jsonArrayValue(body.options),
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
jsonArrayValue(body.correctOptionIndices),
nullableString(body.answerText),
nullableString(body.explanation),
jsonArrayValue(body.subQuestions),
nullableString(body.codeLang),
nullableString(body.codeTemplate),
nullableString(body.sourceHash),
auth.userId,
],
);
await client.query(
`
update public.questions
set current_version_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, question.id, versionResult.rows[0].id],
);
return { ...question, currentVersion: versionResult.rows[0] };
});
return { item };
}
export async function updateQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const questionId = requiredString(body, 'questionId');
const createVersion = body.createVersion === true;
const item = await transaction(async client => {
const questionResult = await client.query(
`
update public.questions
set question_bank_id = coalesce($3::uuid, question_bank_id),
subject_id = coalesce($4::uuid, subject_id),
category_id = coalesce($5::uuid, category_id),
node_id = coalesce($6::uuid, node_id),
type = coalesce(nullif($7, ''), type),
type_label = coalesce(nullif($8, ''), type_label),
difficulty = coalesce($9, difficulty),
tags = case when $10::boolean then $11::jsonb else tags end,
media_url = coalesce(nullif($12, ''), media_url),
status = coalesce(nullif($13, ''), status),
updated_at = now()
where tenant_id = $1 and id = $2
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
`,
[
auth.tenantId,
questionId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
optionalString(body, 'type'),
optionalString(body, 'typeLabel'),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
Object.hasOwn(body, 'tags'),
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
],
);
const question = questionResult.rows[0];
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
if (!createVersion) return question;
const latest = await client.query<{ version_no: number }>(
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
[questionId],
);
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const versionResult = await client.query(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
returning id, version_no as "versionNo", content, options,
correct_option_index as "correctOptionIndex",
correct_option_indices as "correctOptionIndices",
answer_text as "answerText", explanation, sub_questions as "subQuestions",
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
`,
[
auth.tenantId,
questionId,
nextVersionNo,
nullableString(body.content),
jsonArrayValue(body.options),
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
jsonArrayValue(body.correctOptionIndices),
nullableString(body.answerText),
nullableString(body.explanation),
jsonArrayValue(body.subQuestions),
nullableString(body.codeLang),
nullableString(body.codeTemplate),
nullableString(body.sourceHash),
auth.userId,
],
);
await client.query(
`
update public.questions
set current_version_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, questionId, versionResult.rows[0].id],
);
return { ...question, currentVersion: versionResult.rows[0] };
});
return { item };
}
export async function videosAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select id, legacy_id as "legacyId", title, description, video_url as "videoUrl",
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
subject_id as "subjectId", difficulty, sort_order as "order",
is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.video_explanations
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
order by sort_order asc, created_at desc
limit $3
`,
[auth.tenantId, subjectId || null, limit],
);
return { items };
}
export async function upsertVideoRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const id = nullableString(body.id);
const title = requiredString(body, 'title');
const item = await queryOne(
`
insert into public.video_explanations (
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
sort_order, is_active
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7,
$8, $9::jsonb, $10, $11::uuid, $12, $13, $14
)
on conflict (id)
do update set title = excluded.title,
description = excluded.description,
video_url = excluded.video_url,
thumbnail_url = excluded.thumbnail_url,
duration_seconds = excluded.duration_seconds,
knowledge_tags = excluded.knowledge_tags,
is_general = excluded.is_general,
subject_id = excluded.subject_id,
difficulty = excluded.difficulty,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, title, description, video_url as "videoUrl",
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
subject_id as "subjectId", difficulty, sort_order as "order",
is_active as "isActive", updated_at as "updatedAt"
`,
[
id,
auth.tenantId,
nullableString(body.legacyId),
title,
nullableString(body.description),
nullableString(body.videoUrl),
nullableString(body.thumbnailUrl),
body.duration === undefined ? null : intValue(body.duration, 0),
jsonArrayValue(body.knowledgeTags),
boolValue(body.isGeneral, false),
nullableString(body.subjectId),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function bindQuestionVideoRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const questionId = requiredString(body, 'questionId');
const videoId = requiredString(body, 'videoId');
const item = await transaction(async client => {
const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]);
if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]);
if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');
const result = await client.query(
`
insert into public.question_videos (
tenant_id, question_id, video_id, legacy_id, video_type, sort_order
)
values ($1, $2, $3, $4, $5, $6)
returning id, question_id as "questionId", video_id as "videoId",
video_type as "videoType", sort_order as "order", created_at as "createdAt"
`,
[
auth.tenantId,
questionId,
videoId,
nullableString(body.legacyId),
optionalString(body, 'videoType') || 'specific',
intValue(body.order, 0),
],
);
await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [
auth.tenantId,
questionId,
]);
return result.rows[0];
});
return { item };
}
export async function scorelineSchoolsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, region_id as "regionId", name, short_name as "shortName", type,
is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_schools
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, name asc
`,
[auth.tenantId, regionId || null],
);
return { items };
}
export async function upsertScorelineSchoolRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
short_name = excluded.short_name,
type = excluded.type,
is_hot = excluded.is_hot,
sort_order = excluded.sort_order,
updated_at = now()
returning id, region_id as "regionId", name, short_name as "shortName",
type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.shortName),
nullableString(body.type),
boolValue(body.isHot, false),
intValue(body.order, 0),
],
);
return { item };
}
export async function scorelineMajorsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const schoolId = stringParam(ctx, 'schoolId');
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId", name,
sort_order as "order", has_restriction as "hasRestriction",
restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_majors
where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid)
order by sort_order asc, name asc
`,
[auth.tenantId, schoolId || null],
);
return { items };
}
export async function upsertScorelineMajorRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_majors (
id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
school_id = excluded.school_id,
name = excluded.name,
sort_order = excluded.sort_order,
has_restriction = excluded.has_restriction,
restriction_desc = excluded.restriction_desc,
updated_at = now()
returning id, region_id as "regionId", school_id as "schoolId", name,
sort_order as "order", has_restriction as "hasRestriction",
restriction_desc as "restrictionDesc", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
requiredString(body, 'schoolId'),
nullableString(body.legacyId),
requiredString(body, 'name'),
intValue(body.order, 0),
boolValue(body.hasRestriction, false),
nullableString(body.restrictionDesc),
],
);
return { item };
}
export async function scorelineFieldsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, region_id as "regionId", field_key as "fieldKey",
field_name as "fieldName", field_type as "fieldType", unit,
is_filter as "isFilter", is_required as "isRequired",
is_visible as "isVisible", is_trend as "isTrend",
options, placeholder, description, sort_order as "sortOrder"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc
`,
[auth.tenantId, regionId || null],
);
return { items };
}
export async function upsertScorelineFieldRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_fields (
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
unit, is_filter, is_required, is_visible, is_trend, options,
placeholder, description, sort_order
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16
)
on conflict (tenant_id, region_id, field_key)
do update set field_name = excluded.field_name,
field_type = excluded.field_type,
unit = excluded.unit,
is_filter = excluded.is_filter,
is_required = excluded.is_required,
is_visible = excluded.is_visible,
is_trend = excluded.is_trend,
options = excluded.options,
placeholder = excluded.placeholder,
description = excluded.description,
sort_order = excluded.sort_order,
updated_at = now()
returning id, region_id as "regionId", field_key as "fieldKey",
field_name as "fieldName", field_type as "fieldType",
unit, is_filter as "isFilter", is_required as "isRequired",
is_visible as "isVisible", is_trend as "isTrend",
options, placeholder, description, sort_order as "sortOrder",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'fieldKey'),
requiredString(body, 'fieldName'),
optionalString(body, 'fieldType') || 'number',
nullableString(body.unit),
boolValue(body.isFilter, false),
boolValue(body.isRequired, false),
boolValue(body.isVisible, true),
boolValue(body.isTrend, false),
jsonArrayValue(body.options),
nullableString(body.placeholder),
nullableString(body.description),
intValue(body.sortOrder, 0),
],
);
return { item };
}
export async function scorelineRecordsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues",
created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_records
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by year desc, school_name asc, major_name asc
limit $3
`,
[auth.tenantId, regionId || null, limit],
);
return { items };
}
export async function upsertScorelineRecordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_records (
id, tenant_id, region_id, school_id, major_id, legacy_id,
year, school_name, major_name, field_values
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
school_id = excluded.school_id,
major_id = excluded.major_id,
year = excluded.year,
school_name = excluded.school_name,
major_name = excluded.major_name,
field_values = excluded.field_values,
updated_at = now()
returning id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.schoolId),
nullableString(body.majorId),
nullableString(body.legacyId),
intValue(body.year, new Date().getFullYear()),
nullableString(body.schoolName),
nullableString(body.majorName),
jsonObjectValue(body.fieldValues),
],
);
return { item };
}
export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units
where tenant_id = $1
order by sort_order asc, created_at asc
`,
[auth.tenantId],
);
return { items };
}
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_units (
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const unitId = stringParam(ctx, 'unitId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select id, unit_id as "unitId", word, phonetic, meaning, example,
example_translation as "exampleTranslation", difficulty, tags,
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words
where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid)
order by sort_order asc, word asc
limit $3
`,
[auth.tenantId, unitId || null, limit],
);
return { items };
}
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_words (
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
example, example_translation, difficulty, tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
on conflict (id)
do update set unit_id = excluded.unit_id,
word = excluded.word,
phonetic = excluded.phonetic,
meaning = excluded.meaning,
example = excluded.example,
example_translation = excluded.example_translation,
difficulty = excluded.difficulty,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, unit_id as "unitId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.unitId),
nullableString(body.legacyId),
requiredString(body, 'word'),
nullableString(body.phonetic),
nullableString(body.meaning),
nullableString(body.example),
nullableString(body.exampleTranslation),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive", metadata
from public.handbook_subjects
where tenant_id = $1
order by sort_order asc, created_at asc
`,
[auth.tenantId],
);
return { items };
}
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_subjects (
id, tenant_id, region_id, legacy_id, name, type, icon, color,
description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.type),
nullableString(body.icon),
nullableString(body.color),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return { item };
}
export async function handbookChaptersAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const items = await query(
`
select id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive"
from public.handbook_chapters
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
order by sort_order asc, created_at asc
`,
[auth.tenantId, subjectId || null],
);
return { items };
}
export async function upsertHandbookChapterRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_chapters (
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
on conflict (id)
do update set subject_id = excluded.subject_id,
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
requiredString(body, 'subjectId'),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function handbookEntriesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const chapterId = stringParam(ctx, 'chapterId');
const items = await query(
`
select id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive"
from public.handbook_entries
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
order by sort_order asc, created_at asc
`,
[auth.tenantId, chapterId || null],
);
return { items };
}
export async function upsertHandbookEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_entries (
id, tenant_id, chapter_id, legacy_id, title, summary, content,
tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
on conflict (id)
do update set chapter_id = excluded.chapter_id,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
requiredString(body, 'chapterId'),
nullableString(body.legacyId),
requiredString(body, 'title'),
nullableString(body.summary),
nullableString(body.content),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}

View File

@@ -0,0 +1,32 @@
import { HttpError } from '../../core/http.js';
export type JsonObject = Record<string, unknown>;
export function jsonObjectValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
export function jsonArrayValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
export function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
export function intValue(value: unknown, fallback: number) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
export function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function optionalStatus(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid status: ${candidate}`, 'INVALID_STATUS');
}
return candidate;
}

View File

@@ -0,0 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import { resolveTenantRoute } from './routes.js';
export const tenantRoutes: RouteDefinition[] = [
['GET', '/api/tenant/resolve', resolveTenantRoute],
];

View File

@@ -0,0 +1,108 @@
import { config } from '../../core/config.js';
import { queryOne } from '../../core/db.js';
import { getHeader, type RequestContext } from '../../core/http.js';
interface TenantResolveRow {
id: string;
slug: string;
name: string;
status: string;
mode: string;
host: string | null;
brand_name: string | null;
short_name: string | null;
slogan: string | null;
logo_url: string | null;
favicon_url: string | null;
service_wechat: string | null;
service_account_name: string | null;
theme: Record<string, unknown>;
feature_flags: Record<string, unknown>;
admin_feature_flags: Record<string, unknown>;
public_config: Record<string, unknown>;
}
function normalizeHost(host: string) {
return host.split(':')[0]?.trim().toLowerCase() || '';
}
export async function resolveTenantRoute(ctx: RequestContext) {
const hostParam = ctx.url.searchParams.get('host') || '';
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
const row = tenantCode
? await queryOne<TenantResolveRow>(
`
select t.id, t.slug, t.name, t.status, t.mode,
null::text as host,
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
coalesce(s.public_config, '{}'::jsonb) as public_config
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_settings s on s.tenant_id = t.id
where t.slug = $1 and t.status = 'active'
limit 1
`,
[tenantCode],
)
: await queryOne<TenantResolveRow>(
`
select t.id, t.slug, t.name, t.status, t.mode,
d.host::text,
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
coalesce(s.public_config, '{}'::jsonb) as public_config
from public.tenant_domains d
join public.tenants t on t.id = d.tenant_id
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_settings s on s.tenant_id = t.id
where d.host = $1 and d.status = 'active' and t.status = 'active'
limit 1
`,
[requestHost || 'localhost'],
);
if (!row && requestHost !== 'localhost') {
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
return resolveTenantRoute(ctx);
}
if (!row) {
return {
found: false,
message: 'Tenant not found',
lookup: { host: requestHost, tenantCode: tenantCode || null },
};
}
return {
found: true,
tenant: {
id: row.id,
slug: row.slug,
name: row.name,
status: row.status,
mode: row.mode,
host: row.host,
},
branding: {
brandName: row.brand_name || row.name,
shortName: row.short_name || row.name,
slogan: row.slogan || '',
logoUrl: row.logo_url || '',
faviconUrl: row.favicon_url || '',
serviceWechat: row.service_wechat || '',
serviceAccountName: row.service_account_name || '',
theme: row.theme || {},
},
features: row.feature_flags || {},
adminFeatures: row.admin_feature_flags || {},
publicConfig: row.public_config || {},
};
}

View File

@@ -0,0 +1,8 @@
import type { RouteDefinition } from '../../core/router.js';
import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js';
export const videoRoutes: RouteDefinition[] = [
['GET', '/api/questions/videos', questionVideosRoute],
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
['GET', '/api/videos/search', videoSearchRoute],
];

View File

@@ -0,0 +1,111 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
import { query } from '../../core/db.js';
interface QuestionVideoRow {
questionId: string;
videoType: string;
order: number;
id: string;
legacyId: string | null;
title: string;
description: string | null;
videoUrl: string | null;
thumbnailUrl: string | null;
duration: number | null;
knowledgeTags: unknown[];
isGeneral: boolean;
subjectId: string | null;
difficulty: number | null;
}
function videoSelectSql() {
return `
select qv.question_id as "questionId", qv.video_type as "videoType",
qv.sort_order as "order",
v.id, v.legacy_id as "legacyId", v.title, v.description,
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
v.is_general as "isGeneral", v.subject_id as "subjectId",
v.difficulty
from public.question_videos qv
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
`;
}
export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId');
if (!questionId) {
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
}
const videos = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
`,
[tenantId, questionId],
);
return { videos, total: videos.length };
}
export async function questionVideosBatchRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
if (!questionIds.length) {
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
}
const rows = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
order by qv.question_id, qv.sort_order asc, v.sort_order asc
`,
[tenantId, questionIds],
);
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
for (const row of rows) {
data[row.questionId] ||= { hasVideo: true, videos: [] };
data[row.questionId].videos.push(row);
}
return { data };
}
export async function videoSearchRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const tags = stringParam(ctx, 'tags')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
const limit = intParam(ctx, 'limit', 50, 200);
const videos = await query(
`
select id, legacy_id as "legacyId", title, description,
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
is_general as "isGeneral", subject_id as "subjectId",
difficulty, sort_order as "order", created_at as "createdAt"
from public.video_explanations
where tenant_id = $1
and is_active = true
and is_general = true
and ($2::uuid is null or subject_id = $2::uuid)
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
order by sort_order asc, created_at desc
limit $4
`,
[tenantId, subjectId || null, tags, limit],
);
return { videos, total: videos.length };
}

48
apps/api/src/server.ts Normal file
View File

@@ -0,0 +1,48 @@
import http from 'node:http';
import { config } from './core/config.js';
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
import { createRouter } from './core/router.js';
const routes = createRouter();
function resolveHandler(method: string | undefined, url: URL) {
const exact = routes.get(routeKey(method, url.pathname));
if (exact) return exact;
const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/);
if (method === 'GET' && questionVideosMatch?.[1]) {
url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1]));
return routes.get(routeKey(method, '/api/questions/videos'));
}
return null;
}
const server = http.createServer(async (req, res) => {
applyCors(req, res);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const handler = resolveHandler(req.method, url);
if (!handler) {
sendJson(res, 404, { error: 'Not found', path: url.pathname });
return;
}
try {
const result = await handler({ req, res, url });
sendJson(res, 200, result);
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
sendJson(res, statusCode, body);
}
});
server.listen(config.port, () => {
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
});

14
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "../..",
"types": ["node"]
},
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
}