forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
4
apps/api/.env.example
Normal file
4
apps/api/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
PORT=8787
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
DEFAULT_TENANT_SLUG=master
|
||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
34
apps/api/Dockerfile
Normal file
34
apps/api/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json tsconfig.json ./
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages ./packages
|
||||
RUN npm run build:api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
|
||||
EXPOSE 8787
|
||||
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]
|
||||
729
apps/api/package-lock.json
generated
Normal file
729
apps/api/package-lock.json
generated
Normal file
@@ -0,0 +1,729 @@
|
||||
{
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/api/package.json
Normal file
21
apps/api/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@tiku-saas/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/apps/api/src/server.js",
|
||||
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
37
apps/api/src/core/config.ts
Normal file
37
apps/api/src/core/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
|
||||
|
||||
export interface ApiConfig {
|
||||
nodeEnv: string;
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
defaultTenantSlug: string;
|
||||
corsOrigins: string[];
|
||||
authCodePepper: string;
|
||||
authSessionSecret: string;
|
||||
authSmsProvider: string;
|
||||
authCodeTtlSeconds: number;
|
||||
authSmsCooldownSeconds: number;
|
||||
authSessionTtlSeconds: number;
|
||||
platformAdminApiKey: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
loadDotenv();
|
||||
|
||||
const isProduction = envString('NODE_ENV', 'development') === 'production';
|
||||
|
||||
export const config: ApiConfig = {
|
||||
nodeEnv: envString('NODE_ENV', 'development'),
|
||||
port: envNumber('PORT', 8787),
|
||||
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
|
||||
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
|
||||
corsOrigins: envList('CORS_ORIGIN', '*'),
|
||||
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
|
||||
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
|
||||
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
|
||||
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
|
||||
isProduction,
|
||||
};
|
||||
35
apps/api/src/core/db.ts
Normal file
35
apps/api/src/core/db.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type pg from 'pg';
|
||||
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../../packages/db/src/index.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const pool = createPool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
return runQuery<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
return runQueryOne<T>(pool, sql, params);
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
export async function transaction<T>(callback: (client: pg.PoolClient) => Promise<T>) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await callback(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
68
apps/api/src/core/http.ts
Normal file
68
apps/api/src/core/http.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { config } from './config.js';
|
||||
|
||||
export interface RequestContext {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RequestContext) => Promise<unknown>;
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
message: string,
|
||||
public readonly code = 'HTTP_ERROR',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function getHeader(req: IncomingMessage, name: string): string {
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
export function applyCors(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = getHeader(req, 'origin');
|
||||
const allowAll = config.corsOrigins.includes('*');
|
||||
if (allowAll) {
|
||||
res.setHeader('access-control-allow-origin', '*');
|
||||
} else if (origin && config.corsOrigins.includes(origin)) {
|
||||
res.setHeader('access-control-allow-origin', origin);
|
||||
res.setHeader('vary', 'origin');
|
||||
}
|
||||
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
|
||||
}
|
||||
|
||||
export function routeKey(method: string | undefined, pathname: string) {
|
||||
return `${method || 'GET'} ${pathname}`;
|
||||
}
|
||||
|
||||
export function publicErrorBody(error: unknown) {
|
||||
if (error instanceof HttpError) {
|
||||
return {
|
||||
statusCode: error.statusCode,
|
||||
body: { error: error.message, code: error.code },
|
||||
};
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: config.isProduction ? 'Internal server error' : message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
87
apps/api/src/core/request.ts
Normal file
87
apps/api/src/core/request.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { config } from './config.js';
|
||||
import { getHeader, HttpError, type RequestContext } from './http.js';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
const userId =
|
||||
getHeader(ctx.req, 'x-user-id') ||
|
||||
ctx.url.searchParams.get('userId') ||
|
||||
(typeof body?.userId === 'string' ? body.userId : '');
|
||||
|
||||
if (!userId) {
|
||||
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.trunc(value), max);
|
||||
}
|
||||
|
||||
export function stringParam(ctx: RequestContext, name: string) {
|
||||
return ctx.url.searchParams.get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
export async function readJsonBody(ctx: RequestContext): Promise<JsonObject> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of ctx.req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
|
||||
}
|
||||
return parsed as JsonObject;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredString(body: JsonObject, key: string) {
|
||||
const value = body[key];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function optionalString(body: JsonObject, key: string) {
|
||||
const value = body[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function optionalInteger(body: JsonObject, key: string, fallback: number) {
|
||||
const value = Number(body[key] ?? fallback);
|
||||
return Number.isFinite(value) ? Math.trunc(value) : fallback;
|
||||
}
|
||||
|
||||
export function optionalStringArray(body: JsonObject, key: string): string[] {
|
||||
const value = body[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function requirePlatformAdmin(ctx: RequestContext) {
|
||||
const provided = getHeader(ctx.req, 'x-platform-admin-key');
|
||||
if (!provided || provided !== config.platformAdminApiKey) {
|
||||
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
|
||||
}
|
||||
}
|
||||
44
apps/api/src/core/router.ts
Normal file
44
apps/api/src/core/router.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Handler } from './http.js';
|
||||
import { routeKey } from './http.js';
|
||||
import { authRoutes } from '../features/auth/index.js';
|
||||
import { catalogRoutes } from '../features/catalog/index.js';
|
||||
import { commerceRoutes } from '../features/commerce/index.js';
|
||||
import { healthRoutes } from '../features/health/index.js';
|
||||
import { learningRoutes } from '../features/learning/index.js';
|
||||
import { platformAdminRoutes } from '../features/platform-admin/index.js';
|
||||
import { profileRoutes } from '../features/profile/index.js';
|
||||
import { referralRoutes } from '../features/referral/index.js';
|
||||
import { scorelineRoutes } from '../features/scoreline/index.js';
|
||||
import { tenantAdminRoutes } from '../features/tenant-admin/index.js';
|
||||
import { tenantContentRoutes } from '../features/tenant-content/index.js';
|
||||
import { tenantRoutes } from '../features/tenant/index.js';
|
||||
import { videoRoutes } from '../features/video/index.js';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
export type RouteDefinition = [method: HttpMethod, path: string, handler: Handler];
|
||||
|
||||
export function createRouter(definitions: RouteDefinition[] = allRoutes) {
|
||||
const routes = new Map<string, Handler>();
|
||||
|
||||
for (const [method, path, handler] of definitions) {
|
||||
routes.set(routeKey(method, path), handler);
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
const allRoutes: RouteDefinition[] = [
|
||||
...healthRoutes,
|
||||
...authRoutes,
|
||||
...tenantRoutes,
|
||||
...catalogRoutes,
|
||||
...learningRoutes,
|
||||
...profileRoutes,
|
||||
...scorelineRoutes,
|
||||
...videoRoutes,
|
||||
...commerceRoutes,
|
||||
...referralRoutes,
|
||||
...platformAdminRoutes,
|
||||
...tenantAdminRoutes,
|
||||
...tenantContentRoutes,
|
||||
];
|
||||
18
apps/api/src/features/auth/index.ts
Normal file
18
apps/api/src/features/auth/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
logoutRoute,
|
||||
meRoute,
|
||||
oauthProviderPlaceholderRoute,
|
||||
sendSmsCodeRoute,
|
||||
verifySmsCodeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const authRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/auth/sms/send', sendSmsCodeRoute],
|
||||
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
|
||||
['GET', '/api/auth/me', meRoute],
|
||||
['POST', '/api/auth/logout', logoutRoute],
|
||||
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
|
||||
];
|
||||
52
apps/api/src/features/auth/providers.ts
Normal file
52
apps/api/src/features/auth/providers.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
|
||||
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq';
|
||||
|
||||
export interface SmsSendInput {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
code: string;
|
||||
purpose: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SmsSendResult {
|
||||
provider: SmsProviderName;
|
||||
status: 'sent' | 'mocked';
|
||||
providerMessageId?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SmsProvider {
|
||||
name: SmsProviderName;
|
||||
send(input: SmsSendInput): Promise<SmsSendResult>;
|
||||
}
|
||||
|
||||
class MockSmsProvider implements SmsProvider {
|
||||
readonly name = 'mock' as const;
|
||||
|
||||
async send(): Promise<SmsSendResult> {
|
||||
return {
|
||||
provider: this.name,
|
||||
status: 'mocked',
|
||||
raw: { localOnly: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class NotConfiguredSmsProvider implements SmsProvider {
|
||||
constructor(readonly name: SmsProviderName) {}
|
||||
|
||||
async send(): Promise<SmsSendResult> {
|
||||
throw new Error(`${this.name} SMS provider is not configured yet`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSmsProvider(name: string): SmsProvider {
|
||||
if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun');
|
||||
if (name === 'tencent') return new NotConfiguredSmsProvider('tencent');
|
||||
return new MockSmsProvider();
|
||||
}
|
||||
|
||||
export function supportedOAuthProviders(): OAuthProviderName[] {
|
||||
return ['wechat_web', 'wechat_miniapp', 'qq'];
|
||||
}
|
||||
377
apps/api/src/features/auth/routes.ts
Normal file
377
apps/api/src/features/auth/routes.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../../core/config.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
|
||||
import { createSmsProvider } from './providers.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
bearerTokenFrom,
|
||||
clientIpFrom,
|
||||
createLoginSession,
|
||||
findUserBySessionToken,
|
||||
generateSmsCode,
|
||||
hashSmsCode,
|
||||
normalizePurpose,
|
||||
upsertPhoneUser,
|
||||
userAgentFrom,
|
||||
writeLoginEvent,
|
||||
} from './service.js';
|
||||
|
||||
interface SmsCodeRow {
|
||||
id: string;
|
||||
codeHash: string;
|
||||
attempts: number;
|
||||
expiresAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CooldownRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type SmsVerifyResult =
|
||||
| {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
verified: true;
|
||||
purpose: string;
|
||||
phone: string;
|
||||
user?: unknown;
|
||||
isNewUser?: boolean;
|
||||
session?: unknown;
|
||||
};
|
||||
|
||||
function hashEquals(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
async function activeSmsProviderName(tenantId: string) {
|
||||
if (config.authSmsProvider !== 'mock') return config.authSmsProvider;
|
||||
|
||||
const rows = await query<{ provider: string }>(
|
||||
`
|
||||
select provider
|
||||
from public.tenant_auth_providers
|
||||
where tenant_id = $1
|
||||
and provider in ('aliyun', 'tencent', 'mock')
|
||||
and status in ('active', 'testing')
|
||||
order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return rows[0]?.provider || config.authSmsProvider;
|
||||
}
|
||||
|
||||
export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const metadata = jsonObject(body.metadata);
|
||||
|
||||
const cooldownRows = await query<CooldownRow>(
|
||||
`
|
||||
select created_at as "createdAt"
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
and created_at > now() - ($4::text || ' seconds')::interval
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, phone, purpose, config.authSmsCooldownSeconds],
|
||||
);
|
||||
|
||||
const cooldownRow = cooldownRows[0];
|
||||
if (cooldownRow) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
|
||||
const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
|
||||
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
|
||||
}
|
||||
|
||||
const providerName = await activeSmsProviderName(tenantId);
|
||||
const provider = createSmsProvider(providerName);
|
||||
if (config.isProduction && provider.name === 'mock') {
|
||||
throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
|
||||
}
|
||||
|
||||
const code = generateSmsCode();
|
||||
const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata });
|
||||
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const insertResult = await client.query(
|
||||
`
|
||||
insert into public.sms_verification_codes (
|
||||
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
|
||||
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
phone,
|
||||
purpose,
|
||||
codeHash,
|
||||
providerResult.provider,
|
||||
expiresAt,
|
||||
ipAddress || null,
|
||||
userAgent || null,
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
providerStatus: providerResult.status,
|
||||
providerMessageId: providerResult.providerMessageId || null,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: `sms:${providerResult.provider}`,
|
||||
identifier: phone,
|
||||
result: 'sent',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
|
||||
return insertResult.rows[0];
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
expireIn: config.authCodeTtlSeconds,
|
||||
cooldown: config.authSmsCooldownSeconds,
|
||||
debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const code = requiredString(body, 'code');
|
||||
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
|
||||
const result = await transaction<SmsVerifyResult>(async client => {
|
||||
const codeResult = await client.query<SmsCodeRow>(
|
||||
`
|
||||
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, phone, purpose],
|
||||
);
|
||||
|
||||
const smsCode = codeResult.rows[0];
|
||||
if (!smsCode) {
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'failed',
|
||||
failureCode: 'SMS_CODE_NOT_FOUND',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'expired'
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id],
|
||||
);
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'failed',
|
||||
failureCode: 'SMS_CODE_EXPIRED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
|
||||
}
|
||||
|
||||
const matched = hashEquals(smsCode.codeHash, expectedHash);
|
||||
if (!matched) {
|
||||
const nextAttempts = smsCode.attempts + 1;
|
||||
const blocked = nextAttempts >= 5;
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set attempts = attempts + 1,
|
||||
status = case when $3::boolean then 'blocked' else status end
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id, blocked],
|
||||
);
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: blocked ? 'blocked' : 'failed',
|
||||
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, attempts: nextAttempts },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: blocked ? 429 : 400,
|
||||
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
|
||||
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
|
||||
};
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'verified', consumed_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, smsCode.id],
|
||||
);
|
||||
|
||||
if (purpose !== 'login') {
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, sessionIssued: false },
|
||||
});
|
||||
return { ok: true, verified: true, purpose, phone };
|
||||
}
|
||||
|
||||
const { user, isNewUser } = await upsertPhoneUser(client, { tenantId, phone });
|
||||
const session = await createLoginSession(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider: 'sms',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose },
|
||||
});
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider: 'sms',
|
||||
identifier: phone,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, isNewUser },
|
||||
});
|
||||
|
||||
return { ok: true, verified: true, purpose, phone, user, isNewUser, session };
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new HttpError(result.statusCode, result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function meRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) {
|
||||
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
|
||||
}
|
||||
|
||||
const session = await findUserBySessionToken(token);
|
||||
if (!session) {
|
||||
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
|
||||
}
|
||||
if (session.tenantId !== tenantId) {
|
||||
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: session.id,
|
||||
username: session.username,
|
||||
phone: session.phone,
|
||||
name: session.name,
|
||||
avatarUrl: session.avatarUrl,
|
||||
primaryRole: session.primaryRole,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
session: {
|
||||
id: session.sessionId,
|
||||
expiresAt: session.sessionExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) return { ok: true };
|
||||
|
||||
const session = await findUserBySessionToken(token);
|
||||
if (session && session.tenantId === tenantId) {
|
||||
await query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[session.sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
|
||||
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
|
||||
throw new HttpError(
|
||||
501,
|
||||
`${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
|
||||
'PROVIDER_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
281
apps/api/src/features/auth/service.ts
Normal file
281
apps/api/src/features/auth/service.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { config } from '../../core/config.js';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
|
||||
export interface PlatformUserSummary {
|
||||
id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
primaryRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginSessionSummary {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function clientIpFrom(ctx: RequestContext) {
|
||||
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
}
|
||||
|
||||
export function userAgentFrom(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'user-agent');
|
||||
}
|
||||
|
||||
export function normalizeChinaPhone(phone: string) {
|
||||
return phone.replace(/\s+/g, '').replace(/^\+?86/, '');
|
||||
}
|
||||
|
||||
export function assertChinaPhone(phone: string) {
|
||||
const normalized = normalizeChinaPhone(phone);
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizePurpose(value: string) {
|
||||
if (['login', 'bind_phone', 'reset_password'].includes(value)) return value;
|
||||
throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE');
|
||||
}
|
||||
|
||||
export function generateSmsCode() {
|
||||
return crypto.randomInt(100000, 1000000).toString();
|
||||
}
|
||||
|
||||
export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) {
|
||||
return crypto
|
||||
.createHmac('sha256', config.authCodePepper)
|
||||
.update([tenantId, phone, purpose, code].join(':'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function createSessionToken() {
|
||||
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<
|
||||
PlatformUserSummary & {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
sessionExpiresAt: string;
|
||||
}
|
||||
>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
);
|
||||
}
|
||||
|
||||
export function bearerTokenFrom(ctx: RequestContext) {
|
||||
const authorization = getHeader(ctx.req, 'authorization');
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<LoginSessionSummary> {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into app_private.auth_sessions (
|
||||
tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
tokenHash,
|
||||
input.provider,
|
||||
expiresAt,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function upsertPhoneUser(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
},
|
||||
) {
|
||||
const existing = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.user_identities i
|
||||
join public.platform_users u on u.id = i.user_id
|
||||
where i.provider = 'phone'
|
||||
and i.provider_subject = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.phone],
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const user = existing.rows[0];
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: false };
|
||||
}
|
||||
|
||||
const legacyPhoneUser = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.platform_users u
|
||||
where u.phone = $1
|
||||
order by u.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[input.phone],
|
||||
);
|
||||
|
||||
if (legacyPhoneUser.rows[0]) {
|
||||
const user = legacyPhoneUser.rows[0];
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (user_id, provider, provider_subject, phone)
|
||||
values ($1, 'phone', $2, $2)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
phone = excluded.phone,
|
||||
updated_at = now()
|
||||
`,
|
||||
[user.id, input.phone],
|
||||
);
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: false };
|
||||
}
|
||||
|
||||
const userResult = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
insert into public.platform_users (username, phone, primary_role, raw_profile)
|
||||
values ($1, $2, 'student', $3::jsonb)
|
||||
returning id, username, phone, name, avatar_url as "avatarUrl",
|
||||
primary_role as "primaryRole", created_at as "createdAt"
|
||||
`,
|
||||
[`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })],
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (user_id, provider, provider_subject, phone)
|
||||
values ($1, 'phone', $2, $2)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
phone = excluded.phone,
|
||||
updated_at = now()
|
||||
`,
|
||||
[user.id, input.phone],
|
||||
);
|
||||
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: true };
|
||||
}
|
||||
|
||||
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
||||
values ($1, $2, 'student', 'active')
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active', updated_at = now()
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
||||
values ($1, $2, $3::jsonb, '{}'::jsonb)
|
||||
on conflict (tenant_id, user_id) do nothing
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
totalAnswered: 0,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
studyDays: 1,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeLoginEvent(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId?: string | null;
|
||||
provider: string;
|
||||
identifier?: string;
|
||||
result: 'sent' | 'success' | 'failed' | 'blocked';
|
||||
failureCode?: string | null;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.auth_login_events (
|
||||
tenant_id, user_id, provider, identifier, result, failure_code,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId || null,
|
||||
input.provider,
|
||||
input.identifier || null,
|
||||
input.result,
|
||||
input.failureCode || null,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
202
apps/api/src/features/catalog/assets.ts
Normal file
202
apps/api/src/features/catalog/assets.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
interface CatalogAssetRow {
|
||||
id: string;
|
||||
assetType: string;
|
||||
storageProvider: string;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
title: string | null;
|
||||
fileName: string | null;
|
||||
cdnUrl: string | null;
|
||||
previewUrl: string | null;
|
||||
visibility: string;
|
||||
regionId: string | null;
|
||||
subjectId: string | null;
|
||||
}
|
||||
|
||||
function optionalUserId(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
|
||||
}
|
||||
|
||||
function hasUserContext(ctx: RequestContext) {
|
||||
return !!optionalUserId(ctx);
|
||||
}
|
||||
|
||||
function signedDownload(asset: CatalogAssetRow, expiresInSec: number) {
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
if (asset.cdnUrl) {
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: asset.cdnUrl,
|
||||
expiresAt,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1 and user_id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAssetRow) {
|
||||
const now = new Date().toISOString();
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
and (
|
||||
scope_type = 'tenant'
|
||||
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
|
||||
or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid)
|
||||
)
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now, asset.regionId, asset.subjectId],
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
|
||||
if (asset.visibility === 'private') {
|
||||
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
|
||||
}
|
||||
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const member = await hasActiveMembership(tenantId, userId);
|
||||
if (!member) {
|
||||
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
|
||||
}
|
||||
|
||||
if (asset.visibility === 'members') return { userId, svip: false };
|
||||
|
||||
const svip = await hasSvipAccess(tenantId, userId, asset);
|
||||
if (!svip) {
|
||||
throw new HttpError(403, 'SVIP entitlement is required for this asset', 'ASSET_SVIP_REQUIRED');
|
||||
}
|
||||
return { userId, svip: true };
|
||||
}
|
||||
|
||||
export async function assetsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
|
||||
const userPresent = hasUserContext(ctx);
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
|
||||
if (!includeLocked || !userPresent) {
|
||||
filters.push(`visibility in ('public', 'tenant')`);
|
||||
}
|
||||
if (assetType) {
|
||||
params.push(assetType);
|
||||
filters.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`(region_id = $${params.length} or region_id is null)`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`(subject_id = $${params.length} or subject_id is null)`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`(category_id = $${params.length} or category_id is null)`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_key as "assetKey", asset_type as "assetType",
|
||||
title, category as "categoryLabel", description,
|
||||
file_name as "fileName", preview_url as "previewUrl",
|
||||
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
category_id as "categoryId", node_id as "nodeId",
|
||||
sort_order as "order", metadata, created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.content_assets
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
|
||||
if (!assetId) {
|
||||
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
|
||||
}
|
||||
|
||||
const asset = await queryOne<CatalogAssetRow>(
|
||||
`
|
||||
select id, asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, file_name as "fileName",
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
|
||||
region_id as "regionId", subject_id as "subjectId"
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, assetId],
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
id: asset.id,
|
||||
assetType: asset.assetType,
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
previewUrl: asset.previewUrl,
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
download: signedDownload(asset, 900),
|
||||
};
|
||||
}
|
||||
47
apps/api/src/features/catalog/index.ts
Normal file
47
apps/api/src/features/catalog/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { assetDownloadRoute, assetsRoute } from './assets.js';
|
||||
import {
|
||||
announcementsRoute,
|
||||
bannersRoute,
|
||||
categoriesRoute,
|
||||
faqsRoute,
|
||||
handbookChaptersRoute,
|
||||
handbookEntriesRoute,
|
||||
handbookSubjectsRoute,
|
||||
majorsRoute,
|
||||
moduleNodesRoute,
|
||||
productsRoute,
|
||||
questionsRoute,
|
||||
regionModulesRoute,
|
||||
regionsRoute,
|
||||
schoolsRoute,
|
||||
subjectsRoute,
|
||||
svipPlansRoute,
|
||||
timelinesRoute,
|
||||
vocabularyUnitsRoute,
|
||||
vocabularyWordsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const catalogRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/catalog/regions', regionsRoute],
|
||||
['GET', '/api/catalog/region-modules', regionModulesRoute],
|
||||
['GET', '/api/catalog/module-nodes', moduleNodesRoute],
|
||||
['GET', '/api/catalog/schools', schoolsRoute],
|
||||
['GET', '/api/catalog/majors', majorsRoute],
|
||||
['GET', '/api/catalog/subjects', subjectsRoute],
|
||||
['GET', '/api/catalog/categories', categoriesRoute],
|
||||
['GET', '/api/catalog/questions', questionsRoute],
|
||||
['GET', '/api/catalog/assets', assetsRoute],
|
||||
['GET', '/api/catalog/assets/download', assetDownloadRoute],
|
||||
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
|
||||
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
|
||||
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
|
||||
['GET', '/api/catalog/handbook-chapters', handbookChaptersRoute],
|
||||
['GET', '/api/catalog/handbook-entries', handbookEntriesRoute],
|
||||
['GET', '/api/catalog/banners', bannersRoute],
|
||||
['GET', '/api/catalog/faqs', faqsRoute],
|
||||
['GET', '/api/catalog/announcements', announcementsRoute],
|
||||
['GET', '/api/catalog/products', productsRoute],
|
||||
['GET', '/api/catalog/timelines', timelinesRoute],
|
||||
['GET', '/api/catalog/svip-plans', svipPlansRoute],
|
||||
];
|
||||
580
apps/api/src/features/catalog/routes.ts
Normal file
580
apps/api/src/features/catalog/routes.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
|
||||
function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.trunc(value), max);
|
||||
}
|
||||
|
||||
export async function regionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", name, code, short_name as "shortName",
|
||||
full_name as "fullName", icon, pinyin, is_hot as "isHot",
|
||||
is_active as "isActive", sort_order as "order"
|
||||
from public.regions
|
||||
where tenant_id = $1 and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function regionModulesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId", name, type,
|
||||
icon, color, text_color as "textColor", description, route,
|
||||
is_primary_school_module as "isPrimarySchoolModule",
|
||||
is_active as "isActive", sort_order as "order"
|
||||
from public.region_modules
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function moduleNodesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const moduleId = ctx.url.searchParams.get('moduleId');
|
||||
const parentId = ctx.url.searchParams.get('parentId');
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (moduleId) {
|
||||
params.push(moduleId);
|
||||
filters.push(`module_id = $${params.length}`);
|
||||
}
|
||||
if (parentId) {
|
||||
params.push(parentId === 'root' ? null : parentId);
|
||||
filters.push(parentId === 'root' ? 'parent_id is null' : `parent_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", module_id as "moduleId",
|
||||
parent_id as "parentId", legacy_parent_id as "legacyParentId",
|
||||
region_id as "regionId", type, name, path, sort_order as "order",
|
||||
is_active as "isActive", metadata
|
||||
from public.module_nodes
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function schoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
module_id as "moduleId", name,
|
||||
professional_exam_date as "professionalExamDate",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.schools
|
||||
where ${filters.join(' and ')}
|
||||
order by name asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function majorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", name, description, study_tips as "studyTips",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.majors
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function subjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const majorId = ctx.url.searchParams.get('majorId');
|
||||
const moduleId = ctx.url.searchParams.get('moduleId');
|
||||
const type = ctx.url.searchParams.get('type');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
if (majorId) {
|
||||
params.push(majorId);
|
||||
filters.push(`major_id = $${params.length}`);
|
||||
}
|
||||
if (moduleId) {
|
||||
params.push(moduleId);
|
||||
filters.push(`module_id = $${params.length}`);
|
||||
}
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`type = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
module_id as "moduleId", school_id as "schoolId",
|
||||
major_id as "majorId", node_id as "nodeId", name, type,
|
||||
major_legacy_ids as "majorLegacyIds", icon, description, stats,
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.subjects
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function categoriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
if (nodeId) {
|
||||
params.push(nodeId);
|
||||
filters.push(`node_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", subject_id as "subjectId",
|
||||
node_id as "nodeId", name, category_type as "categoryType",
|
||||
sort_order as "order", svip_question_limit as "svipQuestionLimit",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.categories
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function questionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const categoryId = ctx.url.searchParams.get('categoryId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
const limit = intParam(ctx, 'limit', 200, 500);
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`q.subject_id = $${params.length}`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`q.category_id = $${params.length}`);
|
||||
}
|
||||
if (nodeId) {
|
||||
params.push(nodeId);
|
||||
filters.push(`q.node_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
|
||||
q.category_id as "categoryId", q.node_id as "nodeId",
|
||||
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
|
||||
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
|
||||
v.id as "versionId", v.content, v.options,
|
||||
v.correct_option_index as "correctOptionIndex",
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where ${filters.join(' and ')}
|
||||
order by q.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function vocabularyUnitsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_units
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function vocabularyWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const unitId = ctx.url.searchParams.get('unitId');
|
||||
const limit = intParam(ctx, 'limit', 1000, 2000);
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (unitId) {
|
||||
params.push(unitId);
|
||||
filters.push(`unit_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", unit_id as "unitId",
|
||||
word, phonetic, meaning, example,
|
||||
example_translation as "exampleTranslation",
|
||||
difficulty, tags, sort_order as "order",
|
||||
is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_words
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, word asc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookSubjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, type, icon, color, description, sort_order as "order",
|
||||
is_active as "isActive", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_subjects
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookChaptersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", subject_id as "subjectId",
|
||||
name, description, sort_order as "order",
|
||||
is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_chapters
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function handbookEntriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const chapterId = ctx.url.searchParams.get('chapterId');
|
||||
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (chapterId) {
|
||||
params.push(chapterId);
|
||||
filters.push(`chapter_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", chapter_id as "chapterId",
|
||||
title, summary, ${includeContent ? 'content' : 'null::text as content'},
|
||||
tags, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.handbook_entries
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function bannersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
title, subtitle, content, button_text as "buttonText",
|
||||
button_link as "buttonLink", bg_color as "bgColor",
|
||||
border_color as "borderColor", sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.banners
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function faqsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
question, answer, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.faqs
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function announcementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", content, link,
|
||||
bg_color as "bgColor", sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.announcements
|
||||
where tenant_id = $1 and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function productsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', `status = 'active'`];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
title, price_label as "price", link, type, tags, cover,
|
||||
preview_iframe as "previewIframe", detail_images as "detailImages",
|
||||
sort_order as "order", status, created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.products
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function timelinesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", type, title, description,
|
||||
event_date as "eventDate", link, sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.timelines
|
||||
where ${filters.join(' and ')}
|
||||
order by event_date asc nulls last, sort_order asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function svipPlansRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`(region_id = $${params.length} or region_id is null)`);
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, price_cents as "priceCents",
|
||||
(price_cents::numeric / 100.0) as price,
|
||||
original_price_cents as "originalPriceCents",
|
||||
case when original_price_cents is null then null else (original_price_cents::numeric / 100.0) end as "originalPrice",
|
||||
days, description as "desc", per_day_label as "perDay",
|
||||
badge, recommended, coupon_only as "couponOnly",
|
||||
vp_product_id as "vpProductId", vp_enabled as "vpEnabled",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.svip_plans
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, price_cents asc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
18
apps/api/src/features/commerce/index.ts
Normal file
18
apps/api/src/features/commerce/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
confirmManualPaymentRoute,
|
||||
createOrderRoute,
|
||||
entitlementCheckRoute,
|
||||
entitlementsRoute,
|
||||
ordersRoute,
|
||||
redeemActivationCodeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const commerceRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/commerce/orders', createOrderRoute],
|
||||
['GET', '/api/commerce/orders', ordersRoute],
|
||||
['GET', '/api/commerce/entitlements', entitlementsRoute],
|
||||
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
|
||||
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
|
||||
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
||||
];
|
||||
385
apps/api/src/features/commerce/routes.ts
Normal file
385
apps/api/src/features/commerce/routes.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
tenantIdFrom,
|
||||
userIdFrom,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { createOrderNo, grantSvipEntitlement } from './service.js';
|
||||
|
||||
interface PlanRow {
|
||||
id: string;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
days: number;
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
order_no: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
days: number | null;
|
||||
user_id: string | null;
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface EntitlementRow {
|
||||
id: string;
|
||||
entitlementType: string;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
startsAt: string;
|
||||
expiresAt: string | null;
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function createOrderRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const planId = requiredString(body, 'planId');
|
||||
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
|
||||
const payMethod = optionalString(body, 'payMethod') || 'manual';
|
||||
const payProvider = optionalString(body, 'payProvider') || 'manual';
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
|
||||
const plan = await queryOne<PlanRow>(
|
||||
`
|
||||
select id, name, price_cents, days, region_id
|
||||
from public.svip_plans
|
||||
where tenant_id = $1 and id = $2 and is_active = true
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, planId],
|
||||
);
|
||||
|
||||
if (!plan) {
|
||||
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
|
||||
}
|
||||
|
||||
const finalRegionId = regionId || plan.region_id;
|
||||
const amountCents = Math.max(0, plan.price_cents * quantity);
|
||||
const days = Math.max(0, plan.days * quantity);
|
||||
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const orderResult = await client.query(
|
||||
`
|
||||
insert into public.orders (
|
||||
tenant_id, user_id, order_no, status, product_type, product_name,
|
||||
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, 'pending', 'svip', $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
||||
returning id, order_no as "orderNo", status, product_type as "productType",
|
||||
product_name as "productName", amount_cents as "amountCents",
|
||||
pay_method as "payMethod", pay_provider as "payProvider",
|
||||
plan_id as "planId", days, region_id as "regionId",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
orderNo,
|
||||
`${plan.name}${quantity > 1 ? ` x${quantity}` : ''}`,
|
||||
amountCents,
|
||||
payMethod,
|
||||
payProvider,
|
||||
planId,
|
||||
days,
|
||||
finalRegionId,
|
||||
JSON.stringify({ quantity, request: body }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.order_items (tenant_id, order_id, item_type, item_id, name, quantity, unit_amount_cents, total_amount_cents, metadata)
|
||||
values ($1, $2, 'svip_plan', $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
orderResult.rows[0].id,
|
||||
planId,
|
||||
plan.name,
|
||||
quantity,
|
||||
plan.price_cents,
|
||||
amountCents,
|
||||
JSON.stringify({ days: plan.days, totalDays: days }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
|
||||
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
|
||||
`,
|
||||
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
|
||||
);
|
||||
|
||||
return orderResult.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function ordersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<EntitlementRow>(
|
||||
`
|
||||
select id, order_no as "orderNo", status, product_type as "productType",
|
||||
product_name as "productName", amount_cents as "amountCents",
|
||||
pay_method as "payMethod", pay_provider as "payProvider",
|
||||
trade_no as "tradeNo", plan_id as "planId", days,
|
||||
region_id as "regionId", paid_at as "paidAt",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.orders
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function entitlementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const items: EntitlementRow[] = await query<EntitlementRow>(
|
||||
`
|
||||
select id, entitlement_type as "entitlementType",
|
||||
scope_type as "scopeType", scope_id as "scopeId",
|
||||
source_type as "sourceType", source_id as "sourceId",
|
||||
starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata, created_at as "createdAt"
|
||||
from public.entitlements
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by expires_at desc nulls first, created_at desc
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const activeSvip = await queryOne<{
|
||||
expiresAt: string | null;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
}>(
|
||||
`
|
||||
select expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
order by expires_at desc nulls first
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now],
|
||||
);
|
||||
|
||||
const svipExpiresAt =
|
||||
items.find(item => item.entitlementType === 'svip' && item.status === 'active' && !item.expiresAt)?.expiresAt ||
|
||||
items
|
||||
.filter(item => item.entitlementType === 'svip' && item.status === 'active' && item.expiresAt)
|
||||
.map(item => item.expiresAt)
|
||||
.sort()
|
||||
.at(-1) ||
|
||||
activeSvip?.expiresAt ||
|
||||
null;
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: {
|
||||
isSvip: !!activeSvip,
|
||||
svipExpiresAt,
|
||||
svipScopeType: activeSvip?.scopeType || null,
|
||||
svipScopeId: activeSvip?.scopeId || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function entitlementCheckRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId') || '';
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const row = await queryOne<{
|
||||
id: string;
|
||||
expiresAt: string | null;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= $3::timestamptz
|
||||
and (expires_at is null or expires_at > $3::timestamptz)
|
||||
and (
|
||||
scope_type = 'tenant'
|
||||
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
|
||||
)
|
||||
order by case when scope_type = 'region' then 0 else 1 end, expires_at desc nulls first
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, now, regionId || null],
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: !!row,
|
||||
entitlement: row || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const orderNo = requiredString(body, 'orderNo');
|
||||
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
|
||||
const amountCents = optionalInteger(body, 'amountCents', -1);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const orderResult = await client.query<OrderRow>(
|
||||
`
|
||||
select id, order_no, status, amount_cents, days, user_id, region_id
|
||||
from public.orders
|
||||
where tenant_id = $1 and order_no = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, orderNo],
|
||||
);
|
||||
|
||||
const order = orderResult.rows[0];
|
||||
if (!order) {
|
||||
throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
}
|
||||
if (!order.user_id) {
|
||||
throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
|
||||
}
|
||||
if (amountCents >= 0 && amountCents !== order.amount_cents) {
|
||||
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
|
||||
}
|
||||
|
||||
if (order.status === 'paid') {
|
||||
return { orderNo, status: 'paid', idempotent: true };
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.orders
|
||||
set status = 'paid', trade_no = $3, paid_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, order.id, providerTradeNo],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.payments
|
||||
set status = 'paid', provider_trade_no = $3, paid_at = now(), updated_at = now(),
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb
|
||||
where tenant_id = $1 and order_id = $2
|
||||
`,
|
||||
[tenantId, order.id, providerTradeNo, JSON.stringify({ manualConfirm: body })],
|
||||
);
|
||||
|
||||
const entitlement = await grantSvipEntitlement(client, {
|
||||
tenantId,
|
||||
userId: order.user_id,
|
||||
days: order.days || 0,
|
||||
regionId: order.region_id,
|
||||
sourceType: 'order',
|
||||
sourceId: order.id,
|
||||
metadata: { orderNo },
|
||||
});
|
||||
|
||||
return { orderNo, status: 'paid', entitlement };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function redeemActivationCodeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const code = requiredString(body, 'code');
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const codeResult = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
days: number;
|
||||
is_used: boolean;
|
||||
used_region_id: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, code, days, is_used, used_region_id
|
||||
from public.activation_codes
|
||||
where tenant_id = $1 and lower(code::text) = lower($2)
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, code],
|
||||
);
|
||||
|
||||
const activationCode = codeResult.rows[0];
|
||||
if (!activationCode) {
|
||||
throw new HttpError(404, 'Activation code not found', 'ACTIVATION_CODE_NOT_FOUND');
|
||||
}
|
||||
if (activationCode.is_used) {
|
||||
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
|
||||
}
|
||||
|
||||
const finalRegionId = regionId || activationCode.used_region_id;
|
||||
await client.query(
|
||||
`
|
||||
update public.activation_codes
|
||||
set is_used = true, used_by = $3, used_at = now(),
|
||||
used_region_id = coalesce($4::uuid, used_region_id),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, activationCode.id, userId, finalRegionId],
|
||||
);
|
||||
|
||||
const entitlement = await grantSvipEntitlement(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
days: activationCode.days,
|
||||
regionId: finalRegionId,
|
||||
sourceType: 'activation_code',
|
||||
sourceId: activationCode.id,
|
||||
legacySourceId: activationCode.code,
|
||||
metadata: { code: activationCode.code },
|
||||
});
|
||||
|
||||
return { code: activationCode.code, entitlement };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
80
apps/api/src/features/commerce/service.ts
Normal file
80
apps/api/src/features/commerce/service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type pg from 'pg';
|
||||
|
||||
interface EntitlementInput {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
days: number;
|
||||
regionId?: string | null;
|
||||
sourceType: string;
|
||||
sourceId?: string | null;
|
||||
legacySourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
|
||||
const days = Math.max(0, Math.trunc(input.days || 0));
|
||||
const scopeType = input.regionId ? 'region' : 'tenant';
|
||||
const startsAtResult = await client.query<{ starts_at: string }>(
|
||||
`
|
||||
select greatest(
|
||||
now(),
|
||||
coalesce(max(expires_at), now())
|
||||
) as starts_at
|
||||
from public.entitlements
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and scope_type = $3
|
||||
and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid)
|
||||
and status = 'active'
|
||||
`,
|
||||
[input.tenantId, input.userId, scopeType, input.regionId || null],
|
||||
);
|
||||
|
||||
const startsAt = startsAtResult.rows[0]?.starts_at;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.entitlements (
|
||||
tenant_id, user_id, entitlement_type, scope_type, scope_id,
|
||||
source_type, source_id, legacy_source_id, starts_at, expires_at, status, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'svip', $3, $4,
|
||||
$5, $6, $7, $8::timestamptz,
|
||||
$8::timestamptz + ($9::text || ' days')::interval,
|
||||
'active', $10::jsonb
|
||||
)
|
||||
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",
|
||||
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
scopeType,
|
||||
input.regionId || null,
|
||||
input.sourceType,
|
||||
input.sourceId || null,
|
||||
input.legacySourceId || null,
|
||||
startsAt,
|
||||
days,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export function createOrderNo(prefix = 'SVIP') {
|
||||
const now = new Date();
|
||||
const stamp = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('');
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `${prefix}${stamp}${random}`;
|
||||
}
|
||||
6
apps/api/src/features/health/index.ts
Normal file
6
apps/api/src/features/health/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { healthRoute } from './routes.js';
|
||||
|
||||
export const healthRoutes: RouteDefinition[] = [
|
||||
['GET', '/health', async () => healthRoute()],
|
||||
];
|
||||
11
apps/api/src/features/health/routes.ts
Normal file
11
apps/api/src/features/health/routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { queryOne } from '../../core/db.js';
|
||||
|
||||
export async function healthRoute() {
|
||||
const db = await queryOne<{ ok: number }>('select 1 as ok');
|
||||
return {
|
||||
ok: true,
|
||||
service: 'tiku-saas-api',
|
||||
db: db?.ok === 1 ? 'ok' : 'unknown',
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
28
apps/api/src/features/learning/index.ts
Normal file
28
apps/api/src/features/learning/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
createPracticeSessionRoute,
|
||||
favoriteQuestionsRoute,
|
||||
favoriteWordsRoute,
|
||||
resolveWrongQuestionRoute,
|
||||
submitAnswerRoute,
|
||||
toggleFavoriteQuestionRoute,
|
||||
toggleFavoriteWordRoute,
|
||||
updateWordProgressRoute,
|
||||
wordProgressRoute,
|
||||
wordStatsRoute,
|
||||
wrongQuestionsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const learningRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
||||
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
|
||||
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
||||
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
||||
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
||||
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
|
||||
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
|
||||
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],
|
||||
];
|
||||
453
apps/api/src/features/learning/routes.ts
Normal file
453
apps/api/src/features/learning/routes.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
optionalStringArray,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
stringParam,
|
||||
tenantIdFrom,
|
||||
userIdFrom,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
|
||||
interface QuestionAnswerRow {
|
||||
question_id: string;
|
||||
question_version_id: string | null;
|
||||
correct_option_index: number | null;
|
||||
correct_option_indices: unknown;
|
||||
answer_text: string | null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item)).filter(item => item !== '');
|
||||
}
|
||||
|
||||
function normalizeNumberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isFinite(item))
|
||||
.map(item => Math.trunc(item))
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function normalizeAnswerText(value: unknown) {
|
||||
return String(value ?? '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function arraysEqual<T>(left: T[], right: T[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
|
||||
const correctIndices = normalizeNumberArray(row.correct_option_indices);
|
||||
if (correctIndices.length) {
|
||||
return arraysEqual(
|
||||
selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b),
|
||||
correctIndices,
|
||||
);
|
||||
}
|
||||
|
||||
if (row.correct_option_index !== null && row.correct_option_index !== undefined) {
|
||||
return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index;
|
||||
}
|
||||
|
||||
if (row.answer_text) {
|
||||
return normalizeAnswerText(answerText) === normalizeAnswerText(row.answer_text);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createPracticeSessionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const mode = optionalString(body, 'mode') || 'chapter';
|
||||
const targetType = optionalString(body, 'targetType') || null;
|
||||
const targetId = optionalString(body, 'targetId') || null;
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
returning id, tenant_id as "tenantId", user_id as "userId", mode,
|
||||
target_type as "targetType", target_id as "targetId",
|
||||
started_at as "startedAt", finished_at as "finishedAt", metadata
|
||||
`,
|
||||
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const selectedOptions = optionalStringArray(body, 'selectedOptions');
|
||||
const answerText = optionalString(body, 'answerText');
|
||||
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
|
||||
|
||||
const question = await queryOne<QuestionAnswerRow>(
|
||||
`
|
||||
select q.id as question_id, q.current_version_id as question_version_id,
|
||||
v.correct_option_index, v.correct_option_indices, v.answer_text
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, questionId],
|
||||
);
|
||||
|
||||
if (!question) {
|
||||
throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const judged = judgeAnswer(question, selectedOptions, answerText);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const answerResult = await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, question_id, question_version_id, practice_session_id,
|
||||
selected_options, answer_text, is_correct
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
||||
returning id, question_id as "questionId", question_version_id as "questionVersionId",
|
||||
selected_options as "selectedOptions", answer_text as "answerText",
|
||||
is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
`,
|
||||
[tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged],
|
||||
);
|
||||
|
||||
if (judged === false) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
|
||||
values ($1, $2, $3, 1, now(), null)
|
||||
on conflict (tenant_id, user_id, question_id)
|
||||
do update set wrong_count = public.wrong_questions.wrong_count + 1,
|
||||
last_wrong_at = now(),
|
||||
resolved_at = null
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
} else if (judged === true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.wrong_questions
|
||||
set resolved_at = coalesce(resolved_at, now())
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3 and resolved_at is null
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set questions_answered_today = questions_answered_today + 1,
|
||||
stats = coalesce(stats, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
|
||||
'totalAnswered', coalesce((stats->>'totalAnswered')::integer, 0) + 1,
|
||||
'correctCount', case when $3::boolean is true then coalesce((stats->>'correctCount')::integer, 0) + 1 else null end,
|
||||
'wrongCount', case when $3::boolean is false then coalesce((stats->>'wrongCount')::integer, 0) + 1 else null end
|
||||
)),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId, judged],
|
||||
);
|
||||
|
||||
return answerResult.rows[0];
|
||||
});
|
||||
|
||||
return { item: result };
|
||||
}
|
||||
|
||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select fq.question_id as "questionId", fq.source, fq.created_at as "createdAt",
|
||||
q.subject_id as "subjectId", q.category_id as "categoryId",
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.favorite_questions fq
|
||||
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where fq.tenant_id = $1 and fq.user_id = $2
|
||||
order by fq.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const favorite = body.favorite !== false;
|
||||
|
||||
if (favorite) {
|
||||
await query(
|
||||
`
|
||||
insert into public.favorite_questions (tenant_id, user_id, question_id, source)
|
||||
values ($1, $2, $3, 'api')
|
||||
on conflict (tenant_id, user_id, question_id) do nothing
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`
|
||||
delete from public.favorite_questions
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, favorite };
|
||||
}
|
||||
|
||||
export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
|
||||
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
|
||||
q.subject_id as "subjectId", q.category_id as "categoryId",
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where wq.tenant_id = $1 and wq.user_id = $2
|
||||
and ($3::boolean = false or wq.resolved_at is null)
|
||||
order by wq.last_wrong_at desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, unresolvedOnly, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
|
||||
await query(
|
||||
`
|
||||
update public.wrong_questions
|
||||
set resolved_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and question_id = $3
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function normalizeWordStatus(value: string) {
|
||||
if (['new', 'learning', 'mastered', 'reviewing'].includes(value)) return value;
|
||||
return 'learning';
|
||||
}
|
||||
|
||||
export async function wordProgressRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select p.id, p.word_id as "wordId", p.status,
|
||||
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
|
||||
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
|
||||
p.created_at as "createdAt", p.updated_at as "updatedAt",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
|
||||
from public.user_word_progress p
|
||||
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
|
||||
where p.tenant_id = $1 and p.user_id = $2
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
and ($4::text = '' or p.status = $4::text)
|
||||
order by p.updated_at desc
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, userId, unitId || null, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
|
||||
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
|
||||
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
|
||||
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const word = await client.query<{ id: string }>(
|
||||
`
|
||||
select id from public.vocabulary_words
|
||||
where tenant_id = $1 and id = $2 and is_active = true
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, wordId],
|
||||
);
|
||||
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
|
||||
|
||||
const progress = await client.query(
|
||||
`
|
||||
insert into public.user_word_progress (
|
||||
tenant_id, user_id, word_id, status, correct_count, wrong_count,
|
||||
last_review_date, next_review_date
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set status = excluded.status,
|
||||
correct_count = public.user_word_progress.correct_count + $5,
|
||||
wrong_count = public.user_word_progress.wrong_count + $6,
|
||||
last_review_date = now(),
|
||||
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
|
||||
updated_at = now()
|
||||
returning id, word_id as "wordId", status,
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set mastered_words_count = (
|
||||
select count(*) from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2 and status = 'mastered'
|
||||
),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return progress.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function favoriteWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select f.id, f.word_id as "wordId", f.note,
|
||||
coalesce(f.favorited_at, f.created_at) as "favoritedAt",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
|
||||
w.example, w.example_translation as "exampleTranslation",
|
||||
w.difficulty, w.tags
|
||||
from public.user_word_favorites f
|
||||
join public.vocabulary_words w on w.id = f.word_id and w.tenant_id = f.tenant_id
|
||||
where f.tenant_id = $1 and f.user_id = $2
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
order by coalesce(f.favorited_at, f.created_at) desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, unitId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const favorite = body.favorite !== false;
|
||||
const note = optionalString(body, 'note') || null;
|
||||
|
||||
if (favorite) {
|
||||
await query(
|
||||
`
|
||||
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
|
||||
values ($1, $2, $3, $4, now())
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set note = coalesce(excluded.note, public.user_word_favorites.note),
|
||||
favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, userId, wordId, note],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`
|
||||
delete from public.user_word_favorites
|
||||
where tenant_id = $1 and user_id = $2 and word_id = $3
|
||||
`,
|
||||
[tenantId, userId, wordId],
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, favorite };
|
||||
}
|
||||
|
||||
export async function wordStatsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
select
|
||||
count(w.id)::integer as "totalWords",
|
||||
count(p.word_id)::integer as "progressedWords",
|
||||
count(p.word_id) filter (where p.status = 'mastered')::integer as "masteredWords",
|
||||
count(p.word_id) filter (where p.status in ('learning', 'reviewing'))::integer as "learningWords",
|
||||
count(p.word_id) filter (where p.last_review_date::date = current_date)::integer as "todayReviewed",
|
||||
(
|
||||
select count(*)
|
||||
from public.user_word_favorites f
|
||||
join public.vocabulary_words fw on fw.id = f.word_id and fw.tenant_id = f.tenant_id
|
||||
where f.tenant_id = $1 and f.user_id = $2
|
||||
and ($3::uuid is null or fw.unit_id = $3::uuid)
|
||||
)::integer as "favoriteWords"
|
||||
from public.vocabulary_words w
|
||||
left join public.user_word_progress p
|
||||
on p.word_id = w.id and p.tenant_id = w.tenant_id and p.user_id = $2
|
||||
where w.tenant_id = $1 and w.is_active = true
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
`,
|
||||
[tenantId, userId, unitId || null],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
34
apps/api/src/features/platform-admin/index.ts
Normal file
34
apps/api/src/features/platform-admin/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
confirmInvoicePaymentRoute,
|
||||
createInvoiceRoute,
|
||||
createSubscriptionRoute,
|
||||
createTenantInvoiceFromSubscriptionRoute,
|
||||
createTenantRoute,
|
||||
platformOverviewRoute,
|
||||
platformPlansRoute,
|
||||
recordUsageRoute,
|
||||
tenantDetailRoute,
|
||||
tenantInvoicesRoute,
|
||||
tenantsRoute,
|
||||
tenantUsageRoute,
|
||||
updateTenantStatusRoute,
|
||||
upsertBillingProfileRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/overview', platformOverviewRoute],
|
||||
['GET', '/api/platform-admin/plans', platformPlansRoute],
|
||||
['GET', '/api/platform-admin/tenants', tenantsRoute],
|
||||
['POST', '/api/platform-admin/tenants', createTenantRoute],
|
||||
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
|
||||
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
|
||||
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
|
||||
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
||||
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
||||
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
|
||||
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
|
||||
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
||||
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
||||
];
|
||||
871
apps/api/src/features/platform-admin/routes.ts
Normal file
871
apps/api/src/features/platform-admin/routes.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalString,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
requirePlatformAdmin,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import {
|
||||
centsFrom,
|
||||
createInvoiceNo,
|
||||
invoiceSubtotal,
|
||||
normalizeHost,
|
||||
normalizeInvoiceItems,
|
||||
normalizeSlug,
|
||||
quantityFrom,
|
||||
recalculateInvoiceTotals,
|
||||
} from './service.js';
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
}
|
||||
|
||||
function listQuery(ctx: RequestContext, name: string) {
|
||||
return ctx.url.searchParams.get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
function toDateText(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
|
||||
queryOne<{
|
||||
totalTenants: string;
|
||||
activeTenants: string;
|
||||
suspendedTenants: string;
|
||||
trialTenants: string;
|
||||
pastDueTenants: string;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalTenants",
|
||||
count(*) filter (where status = 'active')::text as "activeTenants",
|
||||
count(*) filter (where status = 'suspended')::text as "suspendedTenants",
|
||||
count(*) filter (where billing_status = 'trial')::text as "trialTenants",
|
||||
count(*) filter (where billing_status = 'past_due')::text as "pastDueTenants"
|
||||
from public.tenants
|
||||
`,
|
||||
),
|
||||
queryOne<{
|
||||
unpaidAmountCents: string;
|
||||
paidAmountCents: string;
|
||||
overdueInvoices: string;
|
||||
}>(
|
||||
`
|
||||
select coalesce(sum(balance_cents) filter (where status in ('issued', 'overdue')), 0)::text as "unpaidAmountCents",
|
||||
coalesce(sum(paid_cents), 0)::text as "paidAmountCents",
|
||||
count(*) filter (where status = 'overdue' or (status = 'issued' and due_date < current_date))::text as "overdueInvoices"
|
||||
from public.tenant_invoices
|
||||
`,
|
||||
),
|
||||
queryOne<{ activeSubscriptions: string; expiringSoon: string }>(
|
||||
`
|
||||
select count(*) filter (where status in ('trial', 'active'))::text as "activeSubscriptions",
|
||||
count(*) filter (where status in ('trial', 'active') and expires_at <= now() + interval '30 days')::text as "expiringSoon"
|
||||
from public.tenant_subscriptions
|
||||
`,
|
||||
),
|
||||
queryOne<{ students: string; questions: string; storageGb: string }>(
|
||||
`
|
||||
select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students,
|
||||
coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions,
|
||||
coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb"
|
||||
from public.tenant_usage_records
|
||||
where period_end >= current_date - interval '31 days'
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
item: {
|
||||
tenants: {
|
||||
total: Number(tenantStats?.totalTenants || 0),
|
||||
active: Number(tenantStats?.activeTenants || 0),
|
||||
suspended: Number(tenantStats?.suspendedTenants || 0),
|
||||
trial: Number(tenantStats?.trialTenants || 0),
|
||||
pastDue: Number(tenantStats?.pastDueTenants || 0),
|
||||
},
|
||||
billing: {
|
||||
unpaidAmountCents: Number(invoiceStats?.unpaidAmountCents || 0),
|
||||
paidAmountCents: Number(invoiceStats?.paidAmountCents || 0),
|
||||
overdueInvoices: Number(invoiceStats?.overdueInvoices || 0),
|
||||
},
|
||||
subscriptions: {
|
||||
active: Number(subscriptionStats?.activeSubscriptions || 0),
|
||||
expiringSoon: Number(subscriptionStats?.expiringSoon || 0),
|
||||
},
|
||||
usage: {
|
||||
students: Number(usageStats?.students || 0),
|
||||
questions: Number(usageStats?.questions || 0),
|
||||
storageGb: Number(usageStats?.storageGb || 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function platformPlansRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
|
||||
const items = await query(
|
||||
`
|
||||
select id, code, name, description, billing_cycle as "billingCycle",
|
||||
base_amount_cents as "baseAmountCents", currency, included_quotas as "includedQuotas",
|
||||
overage_prices as "overagePrices", feature_flags as "featureFlags",
|
||||
status, sort_order as "sortOrder", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.platform_saas_plans
|
||||
where ($1::boolean = true or status = 'active')
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[includeArchived],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantsRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const status = listQuery(ctx, 'status');
|
||||
const billingStatus = listQuery(ctx, 'billingStatus');
|
||||
const q = listQuery(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
|
||||
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
|
||||
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
|
||||
b.brand_name as "brandName", b.logo_url as "logoUrl",
|
||||
s.plan_code as "planCode", s.status as "subscriptionStatus",
|
||||
s.expires_at as "subscriptionExpiresAt",
|
||||
coalesce(i.open_balance_cents, 0) as "openBalanceCents"
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join lateral (
|
||||
select plan_code, status, expires_at
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = t.id
|
||||
order by created_at desc
|
||||
limit 1
|
||||
) s on true
|
||||
left join lateral (
|
||||
select sum(balance_cents)::integer as open_balance_cents
|
||||
from public.tenant_invoices
|
||||
where tenant_id = t.id and status in ('issued', 'overdue')
|
||||
) i on true
|
||||
where ($1::text = '' or t.status = $1)
|
||||
and ($2::text = '' or t.billing_status = $2)
|
||||
and (
|
||||
$3::text = ''
|
||||
or t.slug::text ilike '%' || $3 || '%'
|
||||
or t.name ilike '%' || $3 || '%'
|
||||
or coalesce(t.legal_name, '') ilike '%' || $3 || '%'
|
||||
)
|
||||
order by t.created_at desc
|
||||
limit $4
|
||||
`,
|
||||
[status, billingStatus, q, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
|
||||
|
||||
const tenant = await queryOne(
|
||||
`
|
||||
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
|
||||
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
|
||||
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
|
||||
b.brand_name as "brandName", b.short_name as "shortName", b.logo_url as "logoUrl",
|
||||
b.service_wechat as "serviceWechat",
|
||||
bp.billing_name as "billingName", bp.tax_id as "taxId",
|
||||
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
|
||||
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
|
||||
bp.invoice_type as "invoiceType"
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
|
||||
where t.id = $1
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
if (!tenant) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
|
||||
const [domains, subscriptions, invoices, usage] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
select id, host::text, domain_type as "domainType", status, is_primary as "isPrimary",
|
||||
verified_at as "verifiedAt", created_at as "createdAt"
|
||||
from public.tenant_domains
|
||||
where tenant_id = $1
|
||||
order by is_primary desc, created_at asc
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select id, plan_code as "planCode", status, starts_at as "startsAt",
|
||||
expires_at as "expiresAt", billing_cycle as "billingCycle",
|
||||
amount_cents as "amountCents", metadata, created_at as "createdAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 10
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
|
||||
total_cents as "totalCents", paid_cents as "paidCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 20
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
query(
|
||||
`
|
||||
select metric_key as "metricKey", metric_value as "metricValue",
|
||||
period_start as "periodStart", period_end as "periodEnd", metadata,
|
||||
created_at as "createdAt"
|
||||
from public.tenant_usage_records
|
||||
where tenant_id = $1
|
||||
order by period_end desc, metric_key asc
|
||||
limit 50
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
]);
|
||||
|
||||
return { item: { tenant, domains, subscriptions, invoices, usage } };
|
||||
}
|
||||
|
||||
export async function createTenantRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const slug = normalizeSlug(requiredString(body, 'slug'));
|
||||
const name = requiredString(body, 'name');
|
||||
if (!slug) throw new HttpError(400, 'slug is invalid', 'INVALID_SLUG');
|
||||
|
||||
const planCode = optionalString(body, 'planCode') || 'starter_yearly';
|
||||
const mode = optionalString(body, 'mode') || 'saas';
|
||||
const billingStatus = optionalString(body, 'billingStatus') || 'trial';
|
||||
const status = optionalString(body, 'status') || 'active';
|
||||
const legalName = optionalString(body, 'legalName') || null;
|
||||
const primaryHost = optionalString(body, 'primaryHost');
|
||||
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
|
||||
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const planResult = await client.query<{ code: string; billing_cycle: string; base_amount_cents: number }>(
|
||||
`
|
||||
select code, billing_cycle, base_amount_cents
|
||||
from public.platform_saas_plans
|
||||
where code = $1 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[planCode],
|
||||
);
|
||||
const plan = planResult.rows[0];
|
||||
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
|
||||
|
||||
const tenantResult = await client.query(
|
||||
`
|
||||
insert into public.tenants (slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
returning id, slug::text, name, legal_name as "legalName", status, mode,
|
||||
billing_status as "billingStatus", metadata, created_at as "createdAt"
|
||||
`,
|
||||
[slug, name, legalName, status, mode, billingStatus, JSON.stringify(metadata)],
|
||||
);
|
||||
const tenant = tenantResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
|
||||
values ($1, $2, $3, $4)
|
||||
`,
|
||||
[tenant.id, optionalString(body, 'brandName') || name, optionalString(body, 'shortName') || name, optionalString(body, 'slogan') || null],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values ($1, $2::jsonb, $3::jsonb, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
jsonBodyValue(body.featureFlags || {}),
|
||||
jsonBodyValue(body.adminFeatureFlags || {}),
|
||||
jsonBodyValue(body.publicConfig || {}),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone,
|
||||
contact_email, billing_address, invoice_title, invoice_type, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
typeof billing.billingName === 'string' ? billing.billingName : legalName || name,
|
||||
typeof billing.taxId === 'string' ? billing.taxId : null,
|
||||
typeof billing.contactName === 'string' ? billing.contactName : null,
|
||||
typeof billing.contactPhone === 'string' ? billing.contactPhone : null,
|
||||
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
|
||||
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
|
||||
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
|
||||
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
|
||||
jsonBodyValue(billing.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
if (primaryHost) {
|
||||
const host = normalizeHost(primaryHost);
|
||||
if (host) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary, verification_token)
|
||||
values ($1, $2, 'custom', 'pending', true, $3)
|
||||
`,
|
||||
[tenant.id, host, `tenant-${tenant.id.slice(0, 8)}-${Math.random().toString(36).slice(2, 10)}`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
|
||||
const expiresAt =
|
||||
optionalString(body, 'expiresAt') ||
|
||||
(plan.billing_cycle === 'yearly'
|
||||
? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
: null);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
tenant.id,
|
||||
plan.code,
|
||||
billingStatus === 'trial' ? 'trial' : 'active',
|
||||
startsAt,
|
||||
expiresAt,
|
||||
plan.billing_cycle,
|
||||
centsFrom(body.amountCents, plan.base_amount_cents),
|
||||
JSON.stringify({ source: 'platform-admin:createTenant' }),
|
||||
],
|
||||
);
|
||||
|
||||
return tenant;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const status = optionalString(body, 'status');
|
||||
const billingStatus = optionalString(body, 'billingStatus');
|
||||
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
update public.tenants
|
||||
set status = coalesce(nullif($2, ''), status),
|
||||
billing_status = coalesce(nullif($3, ''), billing_status),
|
||||
metadata = metadata || $4::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, slug::text, name, status, billing_status as "billingStatus",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
|
||||
);
|
||||
|
||||
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
|
||||
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
|
||||
on conflict (tenant_id)
|
||||
do update set billing_name = excluded.billing_name,
|
||||
tax_id = excluded.tax_id,
|
||||
contact_name = excluded.contact_name,
|
||||
contact_phone = excluded.contact_phone,
|
||||
contact_email = excluded.contact_email,
|
||||
billing_address = excluded.billing_address,
|
||||
invoice_title = excluded.invoice_title,
|
||||
invoice_type = excluded.invoice_type,
|
||||
bank_name = excluded.bank_name,
|
||||
bank_account_masked = excluded.bank_account_masked,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
|
||||
contact_name as "contactName", contact_phone as "contactPhone",
|
||||
contact_email as "contactEmail", billing_address as "billingAddress",
|
||||
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
|
||||
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
optionalString(body, 'billingName') || null,
|
||||
optionalString(body, 'taxId') || null,
|
||||
optionalString(body, 'contactName') || null,
|
||||
optionalString(body, 'contactPhone') || null,
|
||||
optionalString(body, 'contactEmail') || null,
|
||||
optionalString(body, 'billingAddress') || null,
|
||||
optionalString(body, 'invoiceTitle') || null,
|
||||
optionalString(body, 'invoiceType') || 'none',
|
||||
optionalString(body, 'bankName') || null,
|
||||
optionalString(body, 'bankAccountMasked') || null,
|
||||
jsonBodyValue(body.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const planCode = requiredString(body, 'planCode');
|
||||
|
||||
const plan = await queryOne<{ code: string; billingCycle: string; baseAmountCents: number }>(
|
||||
`
|
||||
select code, billing_cycle as "billingCycle", base_amount_cents as "baseAmountCents"
|
||||
from public.platform_saas_plans
|
||||
where code = $1 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[planCode],
|
||||
);
|
||||
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
|
||||
|
||||
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
|
||||
const expiresAt =
|
||||
optionalString(body, 'expiresAt') ||
|
||||
(plan.billingCycle === 'yearly' ? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString() : null);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
|
||||
returning id, tenant_id as "tenantId", plan_code as "planCode", status,
|
||||
starts_at as "startsAt", expires_at as "expiresAt",
|
||||
billing_cycle as "billingCycle", amount_cents as "amountCents",
|
||||
metadata, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
plan.code,
|
||||
optionalString(body, 'status') || 'active',
|
||||
startsAt,
|
||||
expiresAt,
|
||||
optionalString(body, 'billingCycle') || plan.billingCycle,
|
||||
centsFrom(body.amountCents, plan.baseAmountCents),
|
||||
jsonBodyValue(body.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenants
|
||||
set billing_status = case when $2 = 'active' then 'active' else billing_status end,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[tenantId, optionalString(body, 'status') || 'active'],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantInvoicesRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const status = listQuery(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select i.id, i.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType", i.status,
|
||||
i.currency, i.total_cents as "totalCents", i.paid_cents as "paidCents",
|
||||
i.balance_cents as "balanceCents", i.billing_period_start as "billingPeriodStart",
|
||||
i.billing_period_end as "billingPeriodEnd", i.due_date as "dueDate",
|
||||
i.issued_at as "issuedAt", i.paid_at as "paidAt", i.note,
|
||||
i.created_at as "createdAt", i.updated_at as "updatedAt"
|
||||
from public.tenant_invoices i
|
||||
join public.tenants t on t.id = i.tenant_id
|
||||
where ($1::uuid is null or i.tenant_id = $1::uuid)
|
||||
and ($2::text = '' or i.status = $2)
|
||||
order by i.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId || null, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
interface CreateInvoiceInput {
|
||||
tenantId: string;
|
||||
invoiceNo?: string;
|
||||
invoiceType?: string;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
discountCents?: number;
|
||||
taxCents?: number;
|
||||
billingPeriodStart?: string | null;
|
||||
billingPeriodEnd?: string | null;
|
||||
dueDate?: string | null;
|
||||
note?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
items: ReturnType<typeof normalizeInvoiceItems>;
|
||||
}
|
||||
|
||||
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
||||
|
||||
const discountCents = centsFrom(input.discountCents, 0);
|
||||
const taxCents = centsFrom(input.taxCents, 0);
|
||||
const subtotalCents = invoiceSubtotal(input.items);
|
||||
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
||||
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
||||
|
||||
return transaction(async client => {
|
||||
const invoiceResult = await client.query(
|
||||
`
|
||||
insert into public.tenant_invoices (
|
||||
tenant_id, invoice_no, invoice_type, status, currency,
|
||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, 0, $9,
|
||||
$10::date, $11::date, $12::date,
|
||||
case when $4 = 'draft' then null else now() end,
|
||||
$13, $14::jsonb
|
||||
)
|
||||
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
||||
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoiceNo,
|
||||
input.invoiceType || 'subscription',
|
||||
input.status || 'issued',
|
||||
input.currency || 'CNY',
|
||||
subtotalCents,
|
||||
discountCents,
|
||||
taxCents,
|
||||
totalCents,
|
||||
input.billingPeriodStart || null,
|
||||
input.billingPeriodEnd || null,
|
||||
input.dueDate || null,
|
||||
input.note || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
for (const itemInput of input.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_items (
|
||||
tenant_id, invoice_id, item_type, description,
|
||||
quantity, unit_amount_cents, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoice.id,
|
||||
itemInput.itemType,
|
||||
itemInput.description,
|
||||
itemInput.quantity,
|
||||
itemInput.unitAmountCents,
|
||||
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||
JSON.stringify(itemInput.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await recalculateInvoiceTotals(client, invoice.id);
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const items = normalizeInvoiceItems(body.items);
|
||||
|
||||
const item = await createInvoiceRecord({
|
||||
tenantId,
|
||||
items,
|
||||
invoiceNo: optionalString(body, 'invoiceNo') || undefined,
|
||||
invoiceType: optionalString(body, 'invoiceType') || 'subscription',
|
||||
status: optionalString(body, 'status') || 'issued',
|
||||
currency: optionalString(body, 'currency') || 'CNY',
|
||||
discountCents: centsFrom(body.discountCents, 0),
|
||||
taxCents: centsFrom(body.taxCents, 0),
|
||||
billingPeriodStart: optionalString(body, 'billingPeriodStart') || null,
|
||||
billingPeriodEnd: optionalString(body, 'billingPeriodEnd') || null,
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? (body.metadata as Record<string, unknown>) : {},
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const invoiceId = requiredString(body, 'invoiceId');
|
||||
const amountCents = centsFrom(body.amountCents, -1);
|
||||
if (amountCents <= 0) throw new HttpError(400, 'amountCents must be positive', 'INVALID_AMOUNT');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const invoiceResult = await client.query<{ id: string; balance_cents: number }>(
|
||||
`
|
||||
select id, balance_cents
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, invoiceId],
|
||||
);
|
||||
const invoice = invoiceResult.rows[0];
|
||||
if (!invoice) throw new HttpError(404, 'Invoice not found', 'INVOICE_NOT_FOUND');
|
||||
|
||||
if (amountCents > invoice.balance_cents && invoice.balance_cents > 0) {
|
||||
throw new HttpError(409, 'Payment amount exceeds invoice balance', 'PAYMENT_AMOUNT_EXCEEDS_BALANCE');
|
||||
}
|
||||
|
||||
const paymentNo = optionalString(body, 'paymentNo') || createInvoiceNo('PAY');
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_payments (
|
||||
tenant_id, invoice_id, payment_no, provider, method, status,
|
||||
amount_cents, paid_at, provider_trade_no, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'paid', $6, coalesce($7::timestamptz, now()), $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
invoiceId,
|
||||
paymentNo,
|
||||
optionalString(body, 'provider') || 'manual',
|
||||
optionalString(body, 'method') || 'manual',
|
||||
amountCents,
|
||||
optionalString(body, 'paidAt') || null,
|
||||
optionalString(body, 'providerTradeNo') || null,
|
||||
jsonBodyValue(body.rawPayload || body),
|
||||
],
|
||||
);
|
||||
|
||||
const updatedInvoice = await recalculateInvoiceTotals(client, invoiceId);
|
||||
|
||||
if (updatedInvoice?.status === 'paid') {
|
||||
await client.query(
|
||||
`
|
||||
update public.tenants
|
||||
set billing_status = 'active', updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_subscriptions
|
||||
set status = 'active', updated_at = now()
|
||||
where tenant_id = $1
|
||||
and status in ('trial', 'past_due')
|
||||
and id = (
|
||||
select id
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
order by created_at desc
|
||||
limit 1
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
return updatedInvoice;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function recordUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const metricKey = requiredString(body, 'metricKey');
|
||||
const metricValue = quantityFrom(body.metricValue, 0);
|
||||
const periodStart = optionalString(body, 'periodStart');
|
||||
const periodEnd = optionalString(body, 'periodEnd');
|
||||
if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.tenant_usage_records (
|
||||
tenant_id, metric_key, metric_value, period_start, period_end, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::date, $5::date, $6::jsonb)
|
||||
returning id, tenant_id as "tenantId", metric_key as "metricKey",
|
||||
metric_value as "metricValue", period_start as "periodStart",
|
||||
period_end as "periodEnd", metadata, created_at as "createdAt"
|
||||
`,
|
||||
[tenantId, metricKey, metricValue, periodStart, periodEnd, jsonBodyValue(body.metadata)],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select u.id, u.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
u.metric_key as "metricKey", u.metric_value as "metricValue",
|
||||
u.period_start as "periodStart", u.period_end as "periodEnd",
|
||||
u.metadata, u.created_at as "createdAt"
|
||||
from public.tenant_usage_records u
|
||||
join public.tenants t on t.id = u.tenant_id
|
||||
where ($1::uuid is null or u.tenant_id = $1::uuid)
|
||||
order by u.period_end desc, u.created_at desc
|
||||
limit $2
|
||||
`,
|
||||
[tenantId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const subscriptionId = optionalString(body, 'subscriptionId');
|
||||
|
||||
const subscription = await queryOne<{
|
||||
id: string;
|
||||
planCode: string;
|
||||
amountCents: number;
|
||||
startsAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, plan_code as "planCode", amount_cents as "amountCents",
|
||||
starts_at as "startsAt", expires_at as "expiresAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or id = $2::uuid)
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, subscriptionId || null],
|
||||
);
|
||||
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
|
||||
|
||||
const plan = await queryOne<{ name: string }>(
|
||||
`
|
||||
select name from public.platform_saas_plans
|
||||
where code = $1
|
||||
limit 1
|
||||
`,
|
||||
[subscription.planCode],
|
||||
);
|
||||
|
||||
const item = await createInvoiceRecord({
|
||||
tenantId,
|
||||
invoiceType: 'subscription',
|
||||
status: optionalString(body, 'status') || 'issued',
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
billingPeriodStart: toDateText(subscription.startsAt),
|
||||
billingPeriodEnd: toDateText(subscription.expiresAt),
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: { source: 'subscription', subscriptionId: subscription.id },
|
||||
items: [
|
||||
{
|
||||
itemType: 'subscription',
|
||||
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
|
||||
quantity: 1,
|
||||
unitAmountCents: subscription.amountCents,
|
||||
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
118
apps/api/src/features/platform-admin/service.ts
Normal file
118
apps/api/src/features/platform-admin/service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type pg from 'pg';
|
||||
|
||||
export function createInvoiceNo(prefix = 'BILL') {
|
||||
const now = new Date();
|
||||
const stamp = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('');
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `${prefix}${stamp}${random}`;
|
||||
}
|
||||
|
||||
export function normalizeSlug(slug: string) {
|
||||
return slug
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function normalizeHost(host: string) {
|
||||
return host.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || '';
|
||||
}
|
||||
|
||||
export function centsFrom(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function quantityFrom(value: unknown, fallback = 1) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export interface InvoiceItemInput {
|
||||
itemType: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitAmountCents: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items
|
||||
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
.map(item => ({
|
||||
itemType: typeof item.itemType === 'string' && item.itemType.trim() ? item.itemType.trim() : 'service_fee',
|
||||
description: typeof item.description === 'string' && item.description.trim() ? item.description.trim() : '服务费',
|
||||
quantity: quantityFrom(item.quantity, 1),
|
||||
unitAmountCents: centsFrom(item.unitAmountCents, 0),
|
||||
metadata: item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata) ? (item.metadata as Record<string, unknown>) : {},
|
||||
}))
|
||||
.filter(item => item.unitAmountCents > 0);
|
||||
}
|
||||
|
||||
export function invoiceSubtotal(items: InvoiceItemInput[]) {
|
||||
return items.reduce((sum, item) => sum + Math.round(item.quantity * item.unitAmountCents), 0);
|
||||
}
|
||||
|
||||
export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: string) {
|
||||
const itemResult = await client.query<{ subtotal: string }>(
|
||||
`
|
||||
select coalesce(sum(amount_cents), 0)::text as subtotal
|
||||
from public.tenant_invoice_items
|
||||
where invoice_id = $1
|
||||
`,
|
||||
[invoiceId],
|
||||
);
|
||||
|
||||
const paymentResult = await client.query<{ paid: string }>(
|
||||
`
|
||||
select coalesce(sum(amount_cents), 0)::text as paid
|
||||
from public.tenant_invoice_payments
|
||||
where invoice_id = $1 and status = 'paid'
|
||||
`,
|
||||
[invoiceId],
|
||||
);
|
||||
|
||||
const subtotal = Number(itemResult.rows[0]?.subtotal || 0);
|
||||
const paid = Number(paymentResult.rows[0]?.paid || 0);
|
||||
|
||||
const updateResult = await client.query(
|
||||
`
|
||||
update public.tenant_invoices
|
||||
set subtotal_cents = $2,
|
||||
total_cents = greatest(0, $2 - discount_cents + tax_cents),
|
||||
paid_cents = $3,
|
||||
balance_cents = greatest(0, greatest(0, $2 - discount_cents + tax_cents) - $3),
|
||||
status = case
|
||||
when status = 'void' then status
|
||||
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid'
|
||||
when status = 'draft' then 'issued'
|
||||
else status
|
||||
end,
|
||||
paid_at = case
|
||||
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then coalesce(paid_at, now())
|
||||
else paid_at
|
||||
end,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, invoice_no as "invoiceNo", status, subtotal_cents as "subtotalCents",
|
||||
discount_cents as "discountCents", tax_cents as "taxCents",
|
||||
total_cents as "totalCents", paid_cents as "paidCents",
|
||||
balance_cents as "balanceCents", paid_at as "paidAt"
|
||||
`,
|
||||
[invoiceId, subtotal, paid],
|
||||
);
|
||||
|
||||
return updateResult.rows[0];
|
||||
}
|
||||
7
apps/api/src/features/profile/index.ts
Normal file
7
apps/api/src/features/profile/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
|
||||
|
||||
export const profileRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/profile/me', profileMeRoute],
|
||||
['PATCH', '/api/profile/me', updateProfileMeRoute],
|
||||
];
|
||||
259
apps/api/src/features/profile/routes.ts
Normal file
259
apps/api/src/features/profile/routes.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
primaryRole: string;
|
||||
score: number;
|
||||
regionId: string | null;
|
||||
regionName: string | null;
|
||||
selectedSchoolId: string | null;
|
||||
selectedSchoolName: string | null;
|
||||
selectedMajorId: string | null;
|
||||
selectedMajorName: string | null;
|
||||
questionsAnsweredToday: number;
|
||||
masteredWordsCount: number;
|
||||
lastCheckInDate: string | null;
|
||||
stats: JsonMap;
|
||||
progress: JsonMap;
|
||||
moduleSelections: JsonMap;
|
||||
recentActivities: unknown[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' ? value : {});
|
||||
}
|
||||
|
||||
function jsonArrayBodyValue(value: unknown) {
|
||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'recentLimit', 8, 50);
|
||||
|
||||
const profile = await queryOne<ProfileRow>(
|
||||
`
|
||||
select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name,
|
||||
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score,
|
||||
sp.region_id as "regionId", r.name as "regionName",
|
||||
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
|
||||
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
|
||||
sp.questions_answered_today as "questionsAnsweredToday",
|
||||
sp.mastered_words_count as "masteredWordsCount",
|
||||
sp.last_check_in_date as "lastCheckInDate",
|
||||
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
|
||||
sp.recent_activities as "recentActivities",
|
||||
sp.created_at as "createdAt", sp.updated_at as "updatedAt"
|
||||
from public.student_profiles sp
|
||||
join public.platform_users u on u.id = sp.user_id
|
||||
left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id
|
||||
left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id
|
||||
left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id
|
||||
where sp.tenant_id = $1 and sp.user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||
}
|
||||
|
||||
const recentPractices = await query(
|
||||
`
|
||||
select id, practice_type as "practiceType", target_legacy_id as "targetLegacyId",
|
||||
target_name as "targetName", progress, color,
|
||||
last_access_at as "lastAccessAt", last_practice_at as "lastPracticeAt",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.recent_practices
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by last_practice_at desc nulls last, last_access_at desc nulls last, updated_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
|
||||
const answerStats = await queryOne<{
|
||||
totalAnswered: string;
|
||||
correctCount: string;
|
||||
wrongCount: string;
|
||||
latestAnsweredAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalAnswered",
|
||||
count(*) filter (where is_correct is true)::text as "correctCount",
|
||||
count(*) filter (where is_correct is false)::text as "wrongCount",
|
||||
max(answered_at) as "latestAnsweredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const wordStats = await queryOne<{
|
||||
totalWords: string;
|
||||
progressedWords: string;
|
||||
masteredWords: string;
|
||||
learningWords: string;
|
||||
favoriteWords: string;
|
||||
}>(
|
||||
`
|
||||
select
|
||||
(select count(*) from public.vocabulary_words where tenant_id = $1 and is_active = true)::text as "totalWords",
|
||||
count(*)::text as "progressedWords",
|
||||
count(*) filter (where status = 'mastered')::text as "masteredWords",
|
||||
count(*) filter (where status in ('learning', 'reviewing'))::text as "learningWords",
|
||||
(select count(*) from public.user_word_favorites where tenant_id = $1 and user_id = $2)::text as "favoriteWords"
|
||||
from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const entitlement = await queryOne(
|
||||
`
|
||||
select id, entitlement_type as "entitlementType", scope_type as "scopeType",
|
||||
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
|
||||
status, metadata
|
||||
from public.entitlements
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and entitlement_type = 'svip'
|
||||
and status = 'active'
|
||||
and starts_at <= now()
|
||||
and (expires_at is null or expires_at > now())
|
||||
order by expires_at desc nulls first, created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
const orderSummary = await queryOne<{
|
||||
totalOrders: string;
|
||||
paidOrders: string;
|
||||
paidAmountCents: string;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalOrders",
|
||||
count(*) filter (where status = 'paid')::text as "paidOrders",
|
||||
coalesce(sum(amount_cents) filter (where status = 'paid'), 0)::text as "paidAmountCents"
|
||||
from public.orders
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
...profile,
|
||||
target: {
|
||||
regionId: profile.regionId,
|
||||
regionName: profile.regionName,
|
||||
schoolId: profile.selectedSchoolId,
|
||||
schoolName: profile.selectedSchoolName,
|
||||
majorId: profile.selectedMajorId,
|
||||
majorName: profile.selectedMajorName,
|
||||
},
|
||||
membership: {
|
||||
isSvip: !!entitlement,
|
||||
entitlement,
|
||||
},
|
||||
stats: {
|
||||
...profile.stats,
|
||||
answers: {
|
||||
totalAnswered: Number(answerStats?.totalAnswered || 0),
|
||||
correctCount: Number(answerStats?.correctCount || 0),
|
||||
wrongCount: Number(answerStats?.wrongCount || 0),
|
||||
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
|
||||
},
|
||||
vocabulary: {
|
||||
totalWords: Number(wordStats?.totalWords || 0),
|
||||
progressedWords: Number(wordStats?.progressedWords || 0),
|
||||
masteredWords: Number(wordStats?.masteredWords || 0),
|
||||
learningWords: Number(wordStats?.learningWords || 0),
|
||||
favoriteWords: Number(wordStats?.favoriteWords || 0),
|
||||
},
|
||||
orders: {
|
||||
totalOrders: Number(orderSummary?.totalOrders || 0),
|
||||
paidOrders: Number(orderSummary?.paidOrders || 0),
|
||||
paidAmountCents: Number(orderSummary?.paidAmountCents || 0),
|
||||
},
|
||||
},
|
||||
recentPractices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
|
||||
const name = optionalString(body, 'name') || null;
|
||||
const avatarUrl = optionalString(body, 'avatarUrl') || null;
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
|
||||
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
with updated_user as (
|
||||
update public.platform_users
|
||||
set name = coalesce($3, name),
|
||||
avatar_url = coalesce($4, avatar_url),
|
||||
updated_at = now()
|
||||
where id = $2
|
||||
returning id
|
||||
)
|
||||
insert into public.student_profiles (
|
||||
tenant_id, user_id, region_id, selected_school_id, selected_major_id,
|
||||
stats, progress, module_selections, recent_activities
|
||||
)
|
||||
values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
|
||||
on conflict (tenant_id, user_id)
|
||||
do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
|
||||
selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id),
|
||||
selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id),
|
||||
stats = case when $12::boolean then excluded.stats else public.student_profiles.stats end,
|
||||
progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end,
|
||||
module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end,
|
||||
recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
|
||||
updated_at = now()
|
||||
returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId",
|
||||
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId",
|
||||
stats, progress, module_selections as "moduleSelections",
|
||||
recent_activities as "recentActivities", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
name,
|
||||
avatarUrl,
|
||||
regionId,
|
||||
selectedSchoolId,
|
||||
selectedMajorId,
|
||||
jsonBodyValue(body.stats),
|
||||
jsonBodyValue(body.progress),
|
||||
jsonBodyValue(body.moduleSelections),
|
||||
jsonArrayBodyValue(body.recentActivities),
|
||||
Object.hasOwn(body, 'stats'),
|
||||
Object.hasOwn(body, 'progress'),
|
||||
Object.hasOwn(body, 'moduleSelections'),
|
||||
Object.hasOwn(body, 'recentActivities'),
|
||||
],
|
||||
);
|
||||
|
||||
return { item };
|
||||
}
|
||||
34
apps/api/src/features/referral/index.ts
Normal file
34
apps/api/src/features/referral/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
crmConfigRoute,
|
||||
crmQueueRoute,
|
||||
referralBindRoute,
|
||||
referralInviteCodeRoute,
|
||||
referralManualBindRoute,
|
||||
referralQrcodeRoute,
|
||||
referralResolveRoute,
|
||||
referralSalesClientsRoute,
|
||||
referralSalesStatsRoute,
|
||||
referralStatsRoute,
|
||||
referralTeamRoute,
|
||||
referralTrackEventRoute,
|
||||
upsertCrmConfigRoute,
|
||||
upsertReferralTeamRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const referralRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/referral/invite-code', referralInviteCodeRoute],
|
||||
['POST', '/api/referral/resolve', referralResolveRoute],
|
||||
['POST', '/api/referral/track-event', referralTrackEventRoute],
|
||||
['POST', '/api/referral/bind', referralBindRoute],
|
||||
['GET', '/api/referral/stats', referralStatsRoute],
|
||||
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
|
||||
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
|
||||
['POST', '/api/referral/manual-bind', referralManualBindRoute],
|
||||
['GET', '/api/referral/team', referralTeamRoute],
|
||||
['PUT', '/api/referral/team', upsertReferralTeamRoute],
|
||||
['POST', '/api/referral/qrcode', referralQrcodeRoute],
|
||||
['GET', '/api/crm/config', crmConfigRoute],
|
||||
['PUT', '/api/crm/config', upsertCrmConfigRoute],
|
||||
['GET', '/api/crm/queue', crmQueueRoute],
|
||||
];
|
||||
920
apps/api/src/features/referral/routes.ts
Normal file
920
apps/api/src/features/referral/routes.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
|
||||
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
|
||||
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeCode(code: string) {
|
||||
return code.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function generateReferralCode() {
|
||||
let code = '';
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
code += REFERRAL_CODE_ALPHABET[randomBytes(1)[0] % REFERRAL_CODE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function centsToAmount(cents: number) {
|
||||
return Math.round(cents) / 100;
|
||||
}
|
||||
|
||||
function canViewAllReferral(auth: TenantAdminAuth) {
|
||||
return hasTenantPermission(auth, 'referral:read');
|
||||
}
|
||||
|
||||
function canViewSelfReferral(auth: TenantAdminAuth) {
|
||||
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
|
||||
}
|
||||
|
||||
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1 and user_id = $2 and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return Boolean(result.rows[0]);
|
||||
}
|
||||
|
||||
async function resolveReferralCode(tenantId: string, code: string) {
|
||||
return queryOne<{
|
||||
code: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
role: string | null;
|
||||
}>(
|
||||
`
|
||||
select rc.code::text as code, rc.user_id as "userId", rc.status,
|
||||
u.username, u.name, u.phone, tm.role
|
||||
from public.referral_codes rc
|
||||
join public.platform_users u on u.id = rc.user_id
|
||||
left join lateral (
|
||||
select role
|
||||
from public.tenant_memberships
|
||||
where tenant_id = rc.tenant_id and user_id = rc.user_id and status = 'active'
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'sales' then 3
|
||||
when 'agent' then 4
|
||||
when 'teacher' then 5
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
) tm on true
|
||||
where rc.tenant_id = $1
|
||||
and rc.code = $2::citext
|
||||
and rc.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, normalizeCode(code)],
|
||||
);
|
||||
}
|
||||
|
||||
async function createOrGetReferralCode(client: pg.PoolClient, tenantId: string, userId: string, metadata: Record<string, unknown> = {}) {
|
||||
const existing = await client.query<{ code: string }>(
|
||||
`
|
||||
select code::text
|
||||
from public.referral_codes
|
||||
where tenant_id = $1 and user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
if (existing.rows[0]?.code) return existing.rows[0].code;
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const candidate = generateReferralCode();
|
||||
const inserted = await client.query<{ code: string }>(
|
||||
`
|
||||
insert into public.referral_codes (tenant_id, user_id, code, metadata)
|
||||
values ($1, $2, $3::citext, $4::jsonb)
|
||||
on conflict do nothing
|
||||
returning code::text
|
||||
`,
|
||||
[tenantId, userId, candidate, JSON.stringify(metadata)],
|
||||
);
|
||||
if (inserted.rows[0]?.code) return inserted.rows[0].code;
|
||||
}
|
||||
|
||||
throw new HttpError(500, 'Failed to generate referral code', 'REFERRAL_CODE_GENERATION_FAILED');
|
||||
}
|
||||
|
||||
async function enqueueCrmLead(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
leadId: string;
|
||||
studentUserId: string;
|
||||
source: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const cfg = await client.query<{
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
form_name: string | null;
|
||||
exam_type: string | null;
|
||||
delay_sec: number | null;
|
||||
timeout_sec: number | null;
|
||||
}>(
|
||||
`
|
||||
select enabled, url, form_name, exam_type, delay_sec, timeout_sec
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId],
|
||||
);
|
||||
const config = cfg.rows[0];
|
||||
if (!config?.enabled || !config.url) return null;
|
||||
|
||||
const student = await client.query<{
|
||||
id: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
raw_profile: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
select id, username, name, phone, email::text, raw_profile
|
||||
from public.platform_users
|
||||
where id = $1
|
||||
limit 1
|
||||
`,
|
||||
[input.studentUserId],
|
||||
);
|
||||
|
||||
const lead = await client.query<{
|
||||
ref_code: string | null;
|
||||
referrer_user_id: string | null;
|
||||
bound_at: string;
|
||||
}>(
|
||||
`
|
||||
select ref_code::text, referrer_user_id, bound_at
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.leadId],
|
||||
);
|
||||
|
||||
const payload = {
|
||||
formName: config.form_name || '刷题题库',
|
||||
examType: config.exam_type || '成人本科',
|
||||
source: input.source,
|
||||
leadId: input.leadId,
|
||||
student: {
|
||||
id: input.studentUserId,
|
||||
username: student.rows[0]?.username || null,
|
||||
name: student.rows[0]?.name || null,
|
||||
phone: student.rows[0]?.phone || null,
|
||||
email: student.rows[0]?.email || null,
|
||||
},
|
||||
referral: {
|
||||
refCode: lead.rows[0]?.ref_code || null,
|
||||
referrerUserId: lead.rows[0]?.referrer_user_id || null,
|
||||
boundAt: lead.rows[0]?.bound_at || null,
|
||||
},
|
||||
metadata: input.metadata || {},
|
||||
};
|
||||
|
||||
const delaySeconds = Math.max(0, Number(config.delay_sec || 60));
|
||||
const idempotencyKey = `lead:${input.leadId}`;
|
||||
const queued = await client.query(
|
||||
`
|
||||
insert into public.crm_webhook_queue (
|
||||
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
|
||||
source, payload, idempotency_key, target_url
|
||||
)
|
||||
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, $4, $5, $6::jsonb, $7, $8)
|
||||
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
|
||||
do update set status = case
|
||||
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
|
||||
else 'pending'
|
||||
end,
|
||||
scheduled_at = excluded.scheduled_at,
|
||||
next_attempt_at = null,
|
||||
payload = excluded.payload,
|
||||
target_url = excluded.target_url,
|
||||
updated_at = now()
|
||||
returning id, status, scheduled_at as "scheduledAt", lead_id as "leadId"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.studentUserId,
|
||||
delaySeconds,
|
||||
input.leadId,
|
||||
input.source,
|
||||
JSON.stringify(payload),
|
||||
idempotencyKey,
|
||||
config.url,
|
||||
],
|
||||
);
|
||||
|
||||
return queued.rows[0];
|
||||
}
|
||||
|
||||
async function bindReferralLead(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
studentUserId: string;
|
||||
refCode: string;
|
||||
referrerUserId: string | null;
|
||||
source: string;
|
||||
trackId: string | null;
|
||||
bindType?: 'first_touch' | 'manual' | 'imported';
|
||||
force?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
if (input.referrerUserId && input.referrerUserId === input.studentUserId) {
|
||||
throw new HttpError(400, 'Cannot bind a lead to itself', 'SELF_REFERRAL_NOT_ALLOWED');
|
||||
}
|
||||
|
||||
const existing = await client.query<{
|
||||
id: string;
|
||||
referrerUserId: string | null;
|
||||
refCode: string | null;
|
||||
status: string;
|
||||
boundAt: string;
|
||||
}>(
|
||||
`
|
||||
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
|
||||
status, bound_at as "boundAt"
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and student_user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.studentUserId],
|
||||
);
|
||||
|
||||
if (existing.rows[0] && !input.force) {
|
||||
return { item: existing.rows[0], bound: false, protected: true };
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_leads (
|
||||
tenant_id, student_user_id, referrer_user_id, ref_code, source,
|
||||
first_track_id, bind_type, status, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4::citext, $5, $6::uuid, $7, 'protected', $8::jsonb)
|
||||
on conflict (tenant_id, student_user_id)
|
||||
do update set referrer_user_id = excluded.referrer_user_id,
|
||||
ref_code = excluded.ref_code,
|
||||
source = excluded.source,
|
||||
first_track_id = coalesce(public.referral_leads.first_track_id, excluded.first_track_id),
|
||||
bind_type = excluded.bind_type,
|
||||
status = 'protected',
|
||||
metadata = public.referral_leads.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
||||
ref_code::text as "refCode", source, bind_type as "bindType", status,
|
||||
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.studentUserId,
|
||||
input.referrerUserId,
|
||||
normalizeCode(input.refCode),
|
||||
input.source,
|
||||
input.trackId,
|
||||
input.bindType || 'first_touch',
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
if (input.trackId) {
|
||||
await client.query(
|
||||
'update public.referral_tracks set lead_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[input.tenantId, input.trackId, result.rows[0].id],
|
||||
);
|
||||
}
|
||||
|
||||
return { item: result.rows[0], bound: true, protected: false };
|
||||
}
|
||||
|
||||
function crmPermission(auth: TenantAdminAuth) {
|
||||
requireTenantPermission(auth, 'crm:read');
|
||||
}
|
||||
|
||||
export async function referralInviteCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const activeMember = await userHasTenantMembership(client, tenantId, userId);
|
||||
if (!activeMember) {
|
||||
throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
|
||||
}
|
||||
const code = await createOrGetReferralCode(client, tenantId, userId, { source: 'api' });
|
||||
return { inviteCode: code };
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function referralResolveRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = optionalString(body, 'code');
|
||||
if (!code) return { valid: false };
|
||||
|
||||
const item = await resolveReferralCode(tenantId, code);
|
||||
if (!item) return { valid: false };
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
inviterId: item.userId,
|
||||
inviteCode: item.code,
|
||||
role: item.role,
|
||||
name: item.name || item.username || '推广员',
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralTrackEventRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
|
||||
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const track = await client.query(
|
||||
`
|
||||
insert into public.referral_tracks (
|
||||
tenant_id, event_type, ref_code, ref_user_id, target_user_id, source,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3::citext, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
returning id, event_type as "eventType", ref_code::text as "refCode",
|
||||
ref_user_id as "refUserId", target_user_id as "targetUserId",
|
||||
source, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
eventType,
|
||||
refCode,
|
||||
referrer?.userId || null,
|
||||
targetUserId,
|
||||
source,
|
||||
clientIpFrom(ctx),
|
||||
userAgentFrom(ctx),
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
],
|
||||
);
|
||||
|
||||
let lead = null;
|
||||
let crmQueue = null;
|
||||
if (targetUserId && referrer?.userId && (eventType === 'register' || eventType === 'enter' || eventType === 'scan')) {
|
||||
lead = await bindReferralLead(client, {
|
||||
tenantId,
|
||||
studentUserId: targetUserId,
|
||||
refCode,
|
||||
referrerUserId: referrer.userId,
|
||||
source,
|
||||
trackId: track.rows[0].id,
|
||||
metadata: { eventType },
|
||||
});
|
||||
if (lead.bound) {
|
||||
crmQueue = await enqueueCrmLead(client, {
|
||||
tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId: targetUserId,
|
||||
source,
|
||||
metadata: { trigger: 'referral.track_event', eventType },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { track: track.rows[0], lead, crmQueue };
|
||||
});
|
||||
|
||||
return { item: result.track, lead: result.lead, crmQueue: result.crmQueue };
|
||||
}
|
||||
|
||||
export async function referralBindRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = userIdFrom(ctx, body);
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const lead = await bindReferralLead(client, {
|
||||
tenantId,
|
||||
studentUserId,
|
||||
refCode,
|
||||
referrerUserId: referrer.userId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
trackId: null,
|
||||
metadata: objectValue(body.metadata),
|
||||
});
|
||||
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
|
||||
tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
metadata: { trigger: 'referral.bind' },
|
||||
}) : null;
|
||||
return { lead, crmQueue };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function referralStatsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
|
||||
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
|
||||
|
||||
const item = await queryOne<{
|
||||
referrerUserId: string;
|
||||
leadCount: string;
|
||||
paidLeadCount: string;
|
||||
paidAmountCents: string;
|
||||
trackCount: string;
|
||||
}>(
|
||||
`
|
||||
with leads as (
|
||||
select rl.student_user_id
|
||||
from public.referral_leads rl
|
||||
where rl.tenant_id = $1 and rl.referrer_user_id = $2 and rl.status = 'protected'
|
||||
),
|
||||
paid as (
|
||||
select o.user_id, sum(o.amount_cents)::bigint as amount_cents
|
||||
from public.orders o
|
||||
join leads l on l.student_user_id = o.user_id
|
||||
where o.tenant_id = $1 and o.status = 'paid'
|
||||
group by o.user_id
|
||||
)
|
||||
select $2::uuid as "referrerUserId",
|
||||
(select count(*) from leads)::text as "leadCount",
|
||||
(select count(*) from paid)::text as "paidLeadCount",
|
||||
coalesce((select sum(amount_cents) from paid), 0)::text as "paidAmountCents",
|
||||
(
|
||||
select count(*)
|
||||
from public.referral_tracks rt
|
||||
where rt.tenant_id = $1 and rt.ref_user_id = $2
|
||||
)::text as "trackCount"
|
||||
`,
|
||||
[auth.tenantId, referrerUserId],
|
||||
);
|
||||
|
||||
const paidAmountCents = Number(item?.paidAmountCents || 0);
|
||||
return {
|
||||
item: {
|
||||
referrerUserId,
|
||||
leadCount: Number(item?.leadCount || 0),
|
||||
paidLeadCount: Number(item?.paidLeadCount || 0),
|
||||
paidAmountCents,
|
||||
paidAmount: centsToAmount(paidAmountCents),
|
||||
trackCount: Number(item?.trackCount || 0),
|
||||
conversionRate: Number(item?.leadCount || 0) > 0
|
||||
? Math.round((Number(item?.paidLeadCount || 0) / Number(item?.leadCount || 0)) * 100)
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralSalesStatsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
let scopeSql = '';
|
||||
if (!canViewAllReferral(auth)) {
|
||||
params.push(auth.userId);
|
||||
scopeSql = `and tm.user_id = $${params.length}`;
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query<{
|
||||
referrerUserId: string;
|
||||
role: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
inviteCode: string | null;
|
||||
leadCount: number;
|
||||
paidLeadCount: number;
|
||||
paidAmountCents: string;
|
||||
trackCount: number;
|
||||
}>(
|
||||
`
|
||||
with referrers as (
|
||||
select tm.user_id, tm.role, u.username, u.name, u.phone,
|
||||
rc.code::text as code
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.status = 'active'
|
||||
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
|
||||
${scopeSql}
|
||||
),
|
||||
lead_stats as (
|
||||
select referrer_user_id,
|
||||
count(*)::int as lead_count,
|
||||
count(*) filter (where exists (
|
||||
select 1 from public.orders o
|
||||
where o.tenant_id = rl.tenant_id
|
||||
and o.user_id = rl.student_user_id
|
||||
and o.status = 'paid'
|
||||
))::int as paid_lead_count
|
||||
from public.referral_leads rl
|
||||
where rl.tenant_id = $1 and rl.status = 'protected'
|
||||
group by referrer_user_id
|
||||
),
|
||||
order_stats as (
|
||||
select rl.referrer_user_id, coalesce(sum(o.amount_cents), 0)::bigint as paid_amount_cents
|
||||
from public.referral_leads rl
|
||||
join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id and o.status = 'paid'
|
||||
where rl.tenant_id = $1 and rl.status = 'protected'
|
||||
group by rl.referrer_user_id
|
||||
),
|
||||
track_stats as (
|
||||
select ref_user_id, count(*)::int as track_count
|
||||
from public.referral_tracks
|
||||
where tenant_id = $1
|
||||
group by ref_user_id
|
||||
)
|
||||
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
|
||||
r.code as "inviteCode",
|
||||
coalesce(ls.lead_count, 0) as "leadCount",
|
||||
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
|
||||
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
|
||||
coalesce(ts.track_count, 0) as "trackCount"
|
||||
from referrers r
|
||||
left join lead_stats ls on ls.referrer_user_id = r.user_id
|
||||
left join order_stats os on os.referrer_user_id = r.user_id
|
||||
left join track_stats ts on ts.ref_user_id = r.user_id
|
||||
order by coalesce(ls.lead_count, 0) desc, r.name asc nulls last
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item: Record<string, unknown>) => ({
|
||||
...item,
|
||||
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
|
||||
conversionRate: Number(item.leadCount || 0) > 0
|
||||
? Math.round((Number(item.paidLeadCount || 0) / Number(item.leadCount || 0)) * 100)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralSalesClientsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
|
||||
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query<{
|
||||
id: string;
|
||||
studentUserId: string;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
referrerUserId: string;
|
||||
refCode: string | null;
|
||||
source: string | null;
|
||||
bindType: string;
|
||||
status: string;
|
||||
boundAt: string;
|
||||
paidAmountCents: string;
|
||||
lastPaidAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select rl.id, rl.student_user_id as "studentUserId",
|
||||
u.username, u.name, u.phone, u.email::text as email,
|
||||
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
|
||||
rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt",
|
||||
coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents",
|
||||
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
|
||||
from public.referral_leads rl
|
||||
join public.platform_users u on u.id = rl.student_user_id
|
||||
left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id
|
||||
where rl.tenant_id = $1
|
||||
and rl.referrer_user_id = $2
|
||||
and rl.status = 'protected'
|
||||
group by rl.id, u.id
|
||||
order by rl.bound_at desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, referrerUserId, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item: Record<string, unknown>) => ({
|
||||
...item,
|
||||
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralManualBindRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'referral:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = requiredString(body, 'studentUserId');
|
||||
const referrerUserId = requiredString(body, 'referrerUserId');
|
||||
|
||||
const code = await transaction(async client => {
|
||||
const member = await userHasTenantMembership(client, auth.tenantId, referrerUserId);
|
||||
if (!member) throw new HttpError(400, 'Referrer is not an active tenant member', 'REFERRER_NOT_TENANT_MEMBER');
|
||||
return createOrGetReferralCode(client, auth.tenantId, referrerUserId, { source: 'manual_bind' });
|
||||
});
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const lead = await bindReferralLead(client, {
|
||||
tenantId: auth.tenantId,
|
||||
studentUserId,
|
||||
refCode: code,
|
||||
referrerUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
trackId: null,
|
||||
bindType: 'manual',
|
||||
force: body.force === true,
|
||||
metadata: { operatorUserId: auth.userId, ...objectValue(body.metadata) },
|
||||
});
|
||||
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
|
||||
tenantId: auth.tenantId,
|
||||
leadId: lead.item.id,
|
||||
studentUserId,
|
||||
source: optionalString(body, 'source') || 'manual',
|
||||
metadata: { trigger: 'referral.manual_bind', operatorUserId: auth.userId },
|
||||
}) : null;
|
||||
return { lead, crmQueue };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function referralTeamRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
if (!canViewSelfReferral(auth)) {
|
||||
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||
}
|
||||
const requestedLeaderId = stringParam(ctx, 'leaderUserId');
|
||||
const leaderUserId = canViewAllReferral(auth) && requestedLeaderId ? requestedLeaderId : auth.userId;
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select rte.id, rte.member_user_id as "memberUserId", rte.leader_user_id as "leaderUserId",
|
||||
rte.relation_type as "relationType", rte.status, rte.metadata,
|
||||
u.username, u.name, u.phone,
|
||||
tm.role
|
||||
from public.referral_team_edges rte
|
||||
join public.platform_users u on u.id = rte.member_user_id
|
||||
left join public.tenant_memberships tm on tm.tenant_id = rte.tenant_id
|
||||
and tm.user_id = rte.member_user_id
|
||||
and tm.status = 'active'
|
||||
where rte.tenant_id = $1
|
||||
and rte.leader_user_id = $2
|
||||
and rte.status = 'active'
|
||||
order by rte.created_at asc
|
||||
`,
|
||||
[auth.tenantId, leaderUserId],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertReferralTeamRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'referral:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const memberUserId = requiredString(body, 'memberUserId');
|
||||
const leaderUserId = nullableString(body.leaderUserId);
|
||||
const relationType = optionalChoice(body.relationType, ['sales_team', 'agent_network', 'teacher_class'], 'sales_team');
|
||||
const status = optionalChoice(body.status, ['active', 'disabled'], 'active');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const member = await userHasTenantMembership(client, auth.tenantId, memberUserId);
|
||||
if (!member) throw new HttpError(400, 'Member is not an active tenant member', 'MEMBER_NOT_TENANT_MEMBER');
|
||||
if (leaderUserId) {
|
||||
const leader = await userHasTenantMembership(client, auth.tenantId, leaderUserId);
|
||||
if (!leader) throw new HttpError(400, 'Leader is not an active tenant member', 'LEADER_NOT_TENANT_MEMBER');
|
||||
if (leaderUserId === memberUserId) throw new HttpError(400, 'Leader cannot be the member itself', 'SELF_LEADER_NOT_ALLOWED');
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_team_edges (
|
||||
tenant_id, member_user_id, leader_user_id, relation_type, status, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
on conflict (tenant_id, member_user_id, relation_type)
|
||||
do update set leader_user_id = excluded.leader_user_id,
|
||||
status = excluded.status,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, member_user_id as "memberUserId", leader_user_id as "leaderUserId",
|
||||
relation_type as "relationType", status, metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[auth.tenantId, memberUserId, leaderUserId, relationType, status, JSON.stringify(objectValue(body.metadata))],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function referralQrcodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const page = optionalString(body, 'page') || 'pages/index/index';
|
||||
const provider = optionalString(body, 'provider') || 'wechat-miniapp';
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const activeMember = await userHasTenantMembership(client, tenantId, userId);
|
||||
if (!activeMember) throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
|
||||
const refCode = await createOrGetReferralCode(client, tenantId, userId, { source: 'qrcode' });
|
||||
const scene = optionalString(body, 'scene') || `ref_${refCode}`;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.referral_qrcodes (tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status, metadata)
|
||||
values ($1, $2, $3::citext, $4, $5, $6, $7, 'ready', $8::jsonb)
|
||||
on conflict (tenant_id, provider, scene, page)
|
||||
do update set user_id = excluded.user_id,
|
||||
ref_code = excluded.ref_code,
|
||||
qrcode_url = coalesce(public.referral_qrcodes.qrcode_url, excluded.qrcode_url),
|
||||
status = 'ready',
|
||||
metadata = public.referral_qrcodes.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, ref_code::text as "refCode", scene, page, provider,
|
||||
qrcode_url as "qrcodeUrl", status, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
refCode,
|
||||
scene,
|
||||
page,
|
||||
provider,
|
||||
optionalString(body, 'qrcodeUrl') || `miniapp://${page}?scene=${encodeURIComponent(scene)}`,
|
||||
JSON.stringify({ generatedBy: 'local-placeholder', ...objectValue(body.metadata) }),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function crmConfigRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const item = await queryOne(
|
||||
`
|
||||
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
if (body.secret && typeof body.secret === 'string') {
|
||||
await client.query(
|
||||
`
|
||||
insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider, last_rotated_at)
|
||||
values ($1, 'crm', 'webhook', $2, 'webhook', now())
|
||||
on conflict (tenant_id, secret_scope, secret_key)
|
||||
do update set secret_value = excluded.secret_value,
|
||||
provider = excluded.provider,
|
||||
last_rotated_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
[auth.tenantId, body.secret],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.crm_config (
|
||||
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
on conflict (tenant_id)
|
||||
do update set enabled = excluded.enabled,
|
||||
url = excluded.url,
|
||||
secret_ref = excluded.secret_ref,
|
||||
form_name = excluded.form_name,
|
||||
exam_type = excluded.exam_type,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
delay_sec = excluded.delay_sec,
|
||||
updated_at = now()
|
||||
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
body.enabled === true,
|
||||
nullableString(body.url),
|
||||
secretRef,
|
||||
optionalString(body, 'formName') || '刷题题库',
|
||||
optionalString(body, 'examType') || '成人本科',
|
||||
Number(body.timeoutSec || 10),
|
||||
Number(body.delaySec || 60),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function crmQueueRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, record_id as "recordId", status, scheduled_at as "scheduledAt",
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_webhook_queue
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
18
apps/api/src/features/scoreline/index.ts
Normal file
18
apps/api/src/features/scoreline/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
scorelineFieldsRoute,
|
||||
scorelineMajorsRoute,
|
||||
scorelineRecordsRoute,
|
||||
scorelineSchoolsRoute,
|
||||
scorelineTrendRoute,
|
||||
scorelineYearsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const scorelineRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/scoreline/fields', scorelineFieldsRoute],
|
||||
['GET', '/api/scoreline/schools', scorelineSchoolsRoute],
|
||||
['GET', '/api/scoreline/majors', scorelineMajorsRoute],
|
||||
['GET', '/api/scoreline/records', scorelineRecordsRoute],
|
||||
['GET', '/api/scoreline/trend', scorelineTrendRoute],
|
||||
['GET', '/api/scoreline/years', scorelineYearsRoute],
|
||||
];
|
||||
164
apps/api/src/features/scoreline/routes.ts
Normal file
164
apps/api/src/features/scoreline/routes.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
export async function scorelineFieldsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
field_key as "fieldKey", field_name as "fieldName",
|
||||
field_type as "fieldType", unit, is_filter as "isFilter",
|
||||
is_required as "isRequired", is_visible as "isVisible",
|
||||
is_trend as "isTrend", options, placeholder, description,
|
||||
sort_order as "sortOrder", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_fields
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc, field_name asc
|
||||
`,
|
||||
[tenantId, regionId || null],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineSchoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const q = stringParam(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 200, 1000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
name, short_name as "shortName", type, is_hot as "isHot",
|
||||
sort_order as "order", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_schools
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::text = '' or name ilike '%' || $3 || '%' or short_name ilike '%' || $3 || '%')
|
||||
order by is_hot desc, sort_order asc, name asc, type asc nulls last
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, regionId || null, q, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineMajorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", name, sort_order as "order",
|
||||
has_restriction as "hasRestriction", restriction_desc as "restrictionDesc",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_majors
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
order by sort_order asc, name asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, regionId || null, schoolId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineRecordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
const year = Number(ctx.url.searchParams.get('year') || 0);
|
||||
const page = intParam(ctx, 'page', 1, 10_000);
|
||||
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = `
|
||||
tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
and ($4::uuid is null or major_id = $4::uuid)
|
||||
and ($5::integer = 0 or year = $5::integer)
|
||||
`;
|
||||
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
|
||||
|
||||
const [countRow, items] = await Promise.all([
|
||||
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
|
||||
query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId",
|
||||
school_id as "schoolId", major_id as "majorId", year,
|
||||
school_name as "schoolName", major_name as "majorName",
|
||||
field_values as "fieldValues", created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from public.scoreline_records
|
||||
where ${where}
|
||||
order by year desc, school_name asc nulls last, major_name asc nulls last
|
||||
limit $6 offset $7
|
||||
`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: Number(countRow?.total || 0),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scorelineTrendRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
const limit = intParam(ctx, 'limit', 20, 100);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues"
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id = $3::uuid)
|
||||
and ($4::uuid is null or major_id = $4::uuid)
|
||||
order by year asc, major_name asc nulls last
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, regionId || null, schoolId || null, majorId || null, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scorelineYearsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query<{ year: number }>(
|
||||
`
|
||||
select distinct year
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by year desc
|
||||
`,
|
||||
[tenantId, regionId || null],
|
||||
);
|
||||
|
||||
return { items: items.map(item => item.year) };
|
||||
}
|
||||
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
|
||||
const TENANT_ADMIN_ROLES = new Set([
|
||||
'tenant_owner',
|
||||
'tenant_admin',
|
||||
'tenant_operator',
|
||||
'teacher',
|
||||
'sales',
|
||||
'agent',
|
||||
]);
|
||||
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||
tenant_owner: ['*'],
|
||||
tenant_admin: ['*'],
|
||||
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
|
||||
teacher: ['content:*'],
|
||||
sales: ['codes:*', 'coupons:*', 'referral:*'],
|
||||
agent: ['codes:read', 'coupons:read', 'referral:self'],
|
||||
student: [],
|
||||
};
|
||||
|
||||
export interface TenantAdminAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function permissionKeys(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const keys = [permission];
|
||||
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
||||
keys.push(`${parts.slice(0, i).join(':')}:*`);
|
||||
}
|
||||
keys.push('*');
|
||||
return keys;
|
||||
}
|
||||
|
||||
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
|
||||
for (const key of permissionKeys(permission)) {
|
||||
const value = permissions[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
const explicit = explicitPermission(auth.permissions, permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
|
||||
return defaults.some(defaultPermission => {
|
||||
if (defaultPermission === '*') return true;
|
||||
if (defaultPermission === permission) return true;
|
||||
if (defaultPermission.endsWith(':*')) {
|
||||
return permission.startsWith(defaultPermission.slice(0, -1));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
if (!hasTenantPermission(auth, permission)) {
|
||||
throw new HttpError(403, `Tenant permission is required: ${permission}`, 'TENANT_PERMISSION_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantPermissionCatalog() {
|
||||
return {
|
||||
permissions: [
|
||||
{ key: 'tenant:overview:read', label: '租户概览' },
|
||||
{ key: 'tenant:branding:write', label: '品牌配置' },
|
||||
{ key: 'tenant:settings:write', label: '公开设置' },
|
||||
{ key: 'tenant:domains:read', label: '域名查看' },
|
||||
{ key: 'tenant:domains:write', label: '域名管理' },
|
||||
{ key: 'tenant:payment:read', label: '商户配置查看' },
|
||||
{ key: 'tenant:payment:write', label: '商户配置管理' },
|
||||
{ key: 'tenant:auth:read', label: '登录配置查看' },
|
||||
{ key: 'tenant:auth:write', label: '登录配置管理' },
|
||||
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
|
||||
{ key: 'tenant:secrets:write', label: '密钥轮换' },
|
||||
{ key: 'marketing:read', label: '活动内容查看' },
|
||||
{ key: 'marketing:write', label: '活动内容管理' },
|
||||
{ key: 'codes:read', label: '激活码查看' },
|
||||
{ key: 'codes:write', label: '激活码管理' },
|
||||
{ key: 'coupons:read', label: '优惠券查看' },
|
||||
{ key: 'coupons:write', label: '优惠券管理' },
|
||||
{ key: 'referral:read', label: '客资全局查看' },
|
||||
{ key: 'referral:self', label: '本人客资查看' },
|
||||
{ key: 'referral:write', label: '客资归属管理' },
|
||||
{ key: 'crm:read', label: 'CRM 队列查看' },
|
||||
{ key: 'crm:write', label: 'CRM 入队和重试' },
|
||||
{ key: 'members:read', label: '成员查看' },
|
||||
{ key: 'members:write', label: '成员管理' },
|
||||
{ key: 'audit:read', label: '审计日志查看' },
|
||||
{ key: 'content:*', label: '内容维护' },
|
||||
],
|
||||
roleDefaults: ROLE_PERMISSION_DEFAULTS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
select role, permissions
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and status = 'active'
|
||||
and role = any($3::text[])
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(TENANT_ADMIN_ROLES)],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
}
|
||||
64
apps/api/src/features/tenant-admin/index.ts
Normal file
64
apps/api/src/features/tenant-admin/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
activationCodesRoute,
|
||||
announcementsAdminRoute,
|
||||
auditLogsRoute,
|
||||
authProvidersRoute,
|
||||
bannersAdminRoute,
|
||||
codeBatchesRoute,
|
||||
couponsRoute,
|
||||
createTenantDomainRoute,
|
||||
disableTenantMemberRoute,
|
||||
faqsAdminRoute,
|
||||
generateActivationCodesRoute,
|
||||
paymentAccountsRoute,
|
||||
tenantDomainsRoute,
|
||||
tenantMembersRoute,
|
||||
tenantOverviewRoute,
|
||||
tenantPermissionsRoute,
|
||||
tenantSecretsRoute,
|
||||
upsertTenantMemberRoute,
|
||||
upsertActivationCodeRoute,
|
||||
upsertAnnouncementRoute,
|
||||
upsertAuthProviderRoute,
|
||||
upsertBannerRoute,
|
||||
upsertCodeBatchRoute,
|
||||
upsertCouponRoute,
|
||||
upsertFaqRoute,
|
||||
updateTenantBrandingRoute,
|
||||
updateTenantSettingsRoute,
|
||||
upsertPaymentAccountRoute,
|
||||
upsertTenantSecretRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
|
||||
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
|
||||
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
|
||||
['PUT', '/api/tenant-admin/payment-accounts', upsertPaymentAccountRoute],
|
||||
['GET', '/api/tenant-admin/auth-providers', authProvidersRoute],
|
||||
['PUT', '/api/tenant-admin/auth-providers', upsertAuthProviderRoute],
|
||||
['GET', '/api/tenant-admin/secrets', tenantSecretsRoute],
|
||||
['PUT', '/api/tenant-admin/secrets', upsertTenantSecretRoute],
|
||||
['GET', '/api/tenant-admin/banners', bannersAdminRoute],
|
||||
['PUT', '/api/tenant-admin/banners', upsertBannerRoute],
|
||||
['GET', '/api/tenant-admin/faqs', faqsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
|
||||
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
|
||||
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
|
||||
['PUT', '/api/tenant-admin/activation-codes', upsertActivationCodeRoute],
|
||||
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
|
||||
['GET', '/api/tenant-admin/coupons', couponsRoute],
|
||||
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
|
||||
['GET', '/api/tenant-admin/members', tenantMembersRoute],
|
||||
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
|
||||
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
|
||||
['GET', '/api/tenant-admin/audit-logs', auditLogsRoute],
|
||||
];
|
||||
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
354
apps/api/src/features/tenant-content/assets.ts
Normal file
354
apps/api/src/features/tenant-content/assets.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
|
||||
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
|
||||
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
|
||||
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
||||
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
assetType: string;
|
||||
storageProvider: string;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
title: string | null;
|
||||
fileName: string | null;
|
||||
cdnUrl: string | null;
|
||||
previewUrl: string | null;
|
||||
visibility: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function nullableUuid(value: unknown) {
|
||||
return nullableString(value);
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 160) || 'asset';
|
||||
}
|
||||
|
||||
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
if (asset.cdnUrl) {
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: asset.cdnUrl,
|
||||
expiresAt,
|
||||
signatureMode: 'public-or-provider-managed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: asset.storageProvider,
|
||||
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
if (!id) return;
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
|
||||
[tenantId, id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new HttpError(400, `${table} reference is not in this tenant`, code);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
|
||||
await query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const visibility = stringParam(ctx, 'visibility');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetType) {
|
||||
params.push(assetType);
|
||||
filters.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (visibility) {
|
||||
params.push(visibility);
|
||||
filters.push(`visibility = $${params.length}`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`subject_id = $${params.length}`);
|
||||
}
|
||||
if (categoryId) {
|
||||
params.push(categoryId);
|
||||
filters.push(`category_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.content_assets
|
||||
where ${filters.join(' and ')}
|
||||
order by sort_order asc, created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
|
||||
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
|
||||
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
|
||||
const bucket = nullableString(body.bucket);
|
||||
const objectKey = nullableString(body.objectKey);
|
||||
const cdnUrl = nullableString(body.cdnUrl);
|
||||
const title = requiredString(body, 'title');
|
||||
const regionId = nullableUuid(body.regionId);
|
||||
const subjectId = nullableUuid(body.subjectId);
|
||||
const categoryId = nullableUuid(body.categoryId);
|
||||
const nodeId = nullableUuid(body.nodeId);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
}
|
||||
|
||||
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
|
||||
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.content_assets (
|
||||
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
||||
bucket, object_key, title, category, description, file_name, cdn_url,
|
||||
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
|
||||
is_public, region_id, subject_id, category_id, node_id, status,
|
||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18,
|
||||
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
|
||||
$25, $26::jsonb, $27::jsonb, $28, $28, $29
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
asset_key = excluded.asset_key,
|
||||
asset_type = excluded.asset_type,
|
||||
storage_provider = excluded.storage_provider,
|
||||
bucket = excluded.bucket,
|
||||
object_key = excluded.object_key,
|
||||
title = excluded.title,
|
||||
category = excluded.category,
|
||||
description = excluded.description,
|
||||
file_name = excluded.file_name,
|
||||
cdn_url = excluded.cdn_url,
|
||||
preview_url = excluded.preview_url,
|
||||
mime_type = excluded.mime_type,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
visibility = excluded.visibility,
|
||||
is_public = excluded.is_public,
|
||||
region_id = excluded.region_id,
|
||||
subject_id = excluded.subject_id,
|
||||
category_id = excluded.category_id,
|
||||
node_id = excluded.node_id,
|
||||
status = excluded.status,
|
||||
sort_order = excluded.sort_order,
|
||||
access_rules = excluded.access_rules,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
source = excluded.source,
|
||||
updated_at = now()
|
||||
where public.content_assets.tenant_id = excluded.tenant_id
|
||||
returning id, legacy_id as "legacyId", asset_key as "assetKey",
|
||||
asset_type as "assetType", storage_provider as "storageProvider",
|
||||
bucket, object_key as "objectKey", title, category as "categoryLabel",
|
||||
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
status, sort_order as "order", access_rules as "accessRules",
|
||||
source, download_count as "downloadCount", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.legacyId),
|
||||
nullableString(body.assetKey),
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
title,
|
||||
nullableString(body.categoryLabel) || nullableString(body.category),
|
||||
nullableString(body.description),
|
||||
nullableString(body.fileName),
|
||||
cdnUrl,
|
||||
nullableString(body.previewUrl),
|
||||
nullableString(body.mimeType),
|
||||
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
nullableString(body.checksumSha256),
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
regionId,
|
||||
subjectId,
|
||||
categoryId,
|
||||
nodeId,
|
||||
status,
|
||||
intValue(body.order, 0),
|
||||
jsonObjectValue(body.accessRules),
|
||||
jsonObjectValue(body.metadata),
|
||||
auth.userId,
|
||||
nullableString(body.source) || 'manual',
|
||||
],
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
|
||||
title,
|
||||
assetType,
|
||||
visibility,
|
||||
status,
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const fileName = requiredString(body, 'fileName');
|
||||
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
|
||||
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
|
||||
const bucket = nullableString(body.bucket) || 'tenant-assets';
|
||||
const objectKey =
|
||||
nullableString(body.objectKey) ||
|
||||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
||||
|
||||
return {
|
||||
upload: {
|
||||
provider: storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
method: 'PUT',
|
||||
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
|
||||
headers: {
|
||||
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
|
||||
},
|
||||
expiresAt,
|
||||
signatureMode: 'local-placeholder',
|
||||
},
|
||||
assetDraft: {
|
||||
assetType,
|
||||
storageProvider,
|
||||
bucket,
|
||||
objectKey,
|
||||
fileName,
|
||||
mimeType: nullableString(body.mimeType),
|
||||
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
|
||||
checksumSha256: nullableString(body.checksumSha256),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const assetId = requiredString(body, 'assetId');
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", asset_type as "assetType",
|
||||
storage_provider as "storageProvider", bucket, object_key as "objectKey",
|
||||
title, file_name as "fileName", cdn_url as "cdnUrl",
|
||||
preview_url as "previewUrl", visibility, status
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: asset,
|
||||
download: placeholderSignedUrl(asset, expiresInSec),
|
||||
};
|
||||
}
|
||||
43
apps/api/src/features/tenant-content/auth.ts
Normal file
43
apps/api/src/features/tenant-content/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
|
||||
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
|
||||
|
||||
export interface TenantContentAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
select role, permissions
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and status = 'active'
|
||||
and role = any($3::text[])
|
||||
order by case role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'tenant_operator' then 3
|
||||
when 'teacher' then 4
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(CONTENT_ROLES)],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
}
|
||||
951
apps/api/src/features/tenant-content/imports.ts
Normal file
951
apps/api/src/features/tenant-content/imports.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, nullableString } from './utils.js';
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface ImportIssue {
|
||||
rowNo: number;
|
||||
severity: 'error' | 'warning';
|
||||
code: string;
|
||||
fieldPath: string | null;
|
||||
message: string;
|
||||
details?: JsonObject;
|
||||
}
|
||||
|
||||
interface NormalizedQuestion {
|
||||
legacyId: string | null;
|
||||
type: string;
|
||||
typeLabel: string | null;
|
||||
content: string;
|
||||
options: unknown[];
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: number[];
|
||||
answerText: string | null;
|
||||
explanation: string | null;
|
||||
difficulty: number;
|
||||
tags: string[];
|
||||
mediaUrl: string | null;
|
||||
subQuestions: unknown[];
|
||||
codeLang: string | null;
|
||||
codeTemplate: string | null;
|
||||
sourceHash: string;
|
||||
}
|
||||
|
||||
interface PreviewResult {
|
||||
job: {
|
||||
id: string;
|
||||
status: string;
|
||||
totalCount: number;
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
items: Array<{
|
||||
rowNo: number;
|
||||
status: 'valid' | 'invalid';
|
||||
externalId: string | null;
|
||||
normalized: NormalizedQuestion | null;
|
||||
issues: ImportIssue[];
|
||||
}>;
|
||||
issues: ImportIssue[];
|
||||
}
|
||||
|
||||
const OBJECTIVE_TYPES = new Set(['choice', 'multi', 'judge', 'image']);
|
||||
const READING_TYPE = 'reading';
|
||||
const KNOWN_TYPES = new Set([
|
||||
...OBJECTIVE_TYPES,
|
||||
READING_TYPE,
|
||||
'text',
|
||||
'terms',
|
||||
'short_answer',
|
||||
'composition',
|
||||
'discuss',
|
||||
'translation',
|
||||
'case_analysis',
|
||||
'brief_analysis',
|
||||
'calculation',
|
||||
'analysis_design',
|
||||
'combination',
|
||||
'solution',
|
||||
]);
|
||||
|
||||
function objectValue(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function stringArrayValue(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function numberArrayValue(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(item => Number(item))
|
||||
.filter(Number.isFinite)
|
||||
.map(item => Math.trunc(item));
|
||||
}
|
||||
|
||||
function parseQuestionItems(body: JsonObject) {
|
||||
const source = body.items ?? body.questions ?? body.payload;
|
||||
let parsed: unknown = source;
|
||||
if (typeof source === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new HttpError(400, 'payload must be a valid JSON array string', 'INVALID_IMPORT_PAYLOAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new HttpError(400, 'items/questions/payload must be a JSON array', 'INVALID_IMPORT_PAYLOAD');
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
throw new HttpError(400, 'Import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD');
|
||||
}
|
||||
if (parsed.length > 2000) {
|
||||
throw new HttpError(400, 'A single import job can contain at most 2000 items', 'IMPORT_TOO_LARGE');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function contentHash(value: unknown) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: unknown, issues: ImportIssue[], rowNo: number) {
|
||||
if (value === undefined || value === null || value === '') return 1;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'INVALID_DIFFICULTY',
|
||||
fieldPath: 'difficulty',
|
||||
message: 'difficulty must be a number from 1 to 5',
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
const difficulty = Math.trunc(parsed);
|
||||
if (difficulty < 1 || difficulty > 5) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'DIFFICULTY_OUT_OF_RANGE',
|
||||
fieldPath: 'difficulty',
|
||||
message: 'difficulty is outside the recommended 1-5 range and was clamped',
|
||||
details: { original: value },
|
||||
});
|
||||
}
|
||||
return Math.min(5, Math.max(1, difficulty));
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath = 'options') {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized = value.filter(item => {
|
||||
if (typeof item === 'string') return item.trim() !== '';
|
||||
return item && typeof item === 'object';
|
||||
});
|
||||
|
||||
if (normalized.length !== value.length) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'EMPTY_OPTIONS_REMOVED',
|
||||
fieldPath,
|
||||
message: 'empty options were removed during normalization',
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSubQuestions(value: unknown, issues: ImportIssue[], rowNo: number) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.map((raw, index) => {
|
||||
const sub = objectValue(raw);
|
||||
const fieldPrefix = `sub_questions[${index}]`;
|
||||
const type = stringValue(sub.type) || 'choice';
|
||||
const content = stringValue(sub.content);
|
||||
const options = normalizeOptions(sub.options, issues, rowNo, `${fieldPrefix}.options`);
|
||||
const correctOptionIndices = numberArrayValue(sub.correctOptionIndices ?? sub.correct_option_indices);
|
||||
const answerText = stringValue(sub.answerText ?? sub.answer_text);
|
||||
|
||||
if (!content) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTION_CONTENT_REQUIRED',
|
||||
fieldPath: `${fieldPrefix}.content`,
|
||||
message: 'sub question content is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (OBJECTIVE_TYPES.has(type)) {
|
||||
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo, fieldPrefix);
|
||||
} else if (!answerText) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTION_ANSWER_REQUIRED',
|
||||
fieldPath: `${fieldPrefix}.answerText`,
|
||||
message: 'subjective sub question requires answerText',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
typeLabel: stringValue(sub.typeLabel ?? sub.type_label) || null,
|
||||
content,
|
||||
options,
|
||||
correctOptionIndices,
|
||||
answerText: answerText || null,
|
||||
explanation: stringValue(sub.explanation) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateObjectiveAnswer(
|
||||
options: unknown[],
|
||||
correctOptionIndices: number[],
|
||||
issues: ImportIssue[],
|
||||
rowNo: number,
|
||||
fieldPrefix = '',
|
||||
) {
|
||||
const prefix = fieldPrefix ? `${fieldPrefix}.` : '';
|
||||
if (options.length < 2) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'OPTIONS_REQUIRED',
|
||||
fieldPath: `${prefix}options`,
|
||||
message: 'objective question requires at least two options',
|
||||
});
|
||||
}
|
||||
|
||||
if (correctOptionIndices.length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CORRECT_OPTION_REQUIRED',
|
||||
fieldPath: `${prefix}correctOptionIndices`,
|
||||
message: 'objective question requires correctOptionIndices',
|
||||
});
|
||||
}
|
||||
|
||||
for (const index of correctOptionIndices) {
|
||||
if (index < 0 || index >= options.length) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CORRECT_OPTION_OUT_OF_RANGE',
|
||||
fieldPath: `${prefix}correctOptionIndices`,
|
||||
message: 'correct option index is outside the options range',
|
||||
details: { index, optionsCount: options.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuestion(raw: unknown, rowNo: number) {
|
||||
const issues: ImportIssue[] = [];
|
||||
const source = objectValue(raw);
|
||||
if (!source || Object.keys(source).length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'ROW_OBJECT_REQUIRED',
|
||||
fieldPath: null,
|
||||
message: 'each import row must be an object',
|
||||
});
|
||||
return { normalized: null, issues };
|
||||
}
|
||||
|
||||
const subQuestions = normalizeSubQuestions(source.sub_questions ?? source.subQuestions, issues, rowNo);
|
||||
let type = stringValue(source.type) || (subQuestions.length ? READING_TYPE : 'choice');
|
||||
if (subQuestions.length) type = READING_TYPE;
|
||||
|
||||
if (!KNOWN_TYPES.has(type)) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'warning',
|
||||
code: 'UNKNOWN_QUESTION_TYPE',
|
||||
fieldPath: 'type',
|
||||
message: 'unknown question type was kept for compatibility',
|
||||
details: { type },
|
||||
});
|
||||
}
|
||||
|
||||
const content = stringValue(source.content);
|
||||
if (!content) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'CONTENT_REQUIRED',
|
||||
fieldPath: 'content',
|
||||
message: 'question content is required',
|
||||
});
|
||||
}
|
||||
|
||||
const options = normalizeOptions(source.options, issues, rowNo);
|
||||
const legacyCorrectIndex = source.correctOptionIndex ?? source.correct_option_index;
|
||||
const correctOptionIndices = numberArrayValue(source.correctOptionIndices ?? source.correct_option_indices);
|
||||
if (correctOptionIndices.length === 0 && legacyCorrectIndex !== undefined && legacyCorrectIndex !== null && legacyCorrectIndex !== '') {
|
||||
const parsed = Number(legacyCorrectIndex);
|
||||
if (Number.isFinite(parsed)) correctOptionIndices.push(Math.trunc(parsed));
|
||||
}
|
||||
const answerText = stringValue(source.answerText ?? source.answer_text);
|
||||
|
||||
if (OBJECTIVE_TYPES.has(type)) {
|
||||
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo);
|
||||
} else if (type === READING_TYPE) {
|
||||
if (subQuestions.length === 0) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'SUB_QUESTIONS_REQUIRED',
|
||||
fieldPath: 'sub_questions',
|
||||
message: 'reading question requires sub_questions',
|
||||
});
|
||||
}
|
||||
} else if (!answerText) {
|
||||
issues.push({
|
||||
rowNo,
|
||||
severity: 'error',
|
||||
code: 'ANSWER_TEXT_REQUIRED',
|
||||
fieldPath: 'answerText',
|
||||
message: 'subjective question requires answerText',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedWithoutHash = {
|
||||
legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null,
|
||||
type,
|
||||
typeLabel: stringValue(source.typeLabel ?? source.type_label) || null,
|
||||
content,
|
||||
options,
|
||||
correctOptionIndex: correctOptionIndices.length === 1 ? correctOptionIndices[0] : null,
|
||||
correctOptionIndices,
|
||||
answerText: answerText || null,
|
||||
explanation: stringValue(source.explanation) || null,
|
||||
difficulty: normalizeDifficulty(source.difficulty, issues, rowNo),
|
||||
tags: stringArrayValue(source.tags),
|
||||
mediaUrl: stringValue(source.mediaUrl ?? source.media_url) || null,
|
||||
subQuestions,
|
||||
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
|
||||
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
|
||||
};
|
||||
|
||||
const normalized: NormalizedQuestion = {
|
||||
...normalizedWithoutHash,
|
||||
sourceHash: contentHash(normalizedWithoutHash),
|
||||
};
|
||||
|
||||
return { normalized, issues };
|
||||
}
|
||||
|
||||
async function assertTargetReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
|
||||
const subjectId = requiredString(body, 'subjectId');
|
||||
const categoryId = requiredString(body, 'categoryId');
|
||||
const nodeId = nullableString(body.nodeId);
|
||||
const questionBankId = nullableString(body.questionBankId);
|
||||
const regionId = nullableString(body.regionId);
|
||||
|
||||
const subject = await client.query(
|
||||
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, subjectId],
|
||||
);
|
||||
if (!subject.rows[0]) {
|
||||
throw new HttpError(400, 'subjectId is not in this tenant', 'SUBJECT_NOT_FOUND');
|
||||
}
|
||||
|
||||
const category = await client.query(
|
||||
'select id from public.categories where tenant_id = $1 and id = $2 and subject_id = $3 limit 1',
|
||||
[auth.tenantId, categoryId, subjectId],
|
||||
);
|
||||
if (!category.rows[0]) {
|
||||
throw new HttpError(400, 'categoryId is not in this tenant or subject', 'CATEGORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (nodeId) {
|
||||
const node = await client.query(
|
||||
'select id from public.module_nodes where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, nodeId],
|
||||
);
|
||||
if (!node.rows[0]) {
|
||||
throw new HttpError(400, 'nodeId is not in this tenant', 'NODE_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
if (questionBankId) {
|
||||
const questionBank = await client.query(
|
||||
'select id from public.question_banks where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, questionBankId],
|
||||
);
|
||||
if (!questionBank.rows[0]) {
|
||||
throw new HttpError(400, 'questionBankId is not in this tenant', 'QUESTION_BANK_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
if (regionId) {
|
||||
const region = await client.query(
|
||||
'select id from public.regions where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, regionId],
|
||||
);
|
||||
if (!region.rows[0]) {
|
||||
throw new HttpError(400, 'regionId is not in this tenant', 'REGION_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
return { subjectId, categoryId, nodeId, questionBankId, regionId };
|
||||
}
|
||||
|
||||
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
|
||||
const rawItems = parseQuestionItems(body);
|
||||
const sourceFormat = stringValue(body.sourceFormat) || 'json';
|
||||
const sourceName = stringValue(body.sourceName) || null;
|
||||
|
||||
if (sourceFormat !== 'json') {
|
||||
throw new HttpError(400, 'Only json sourceFormat is supported by the synchronous API for now', 'IMPORT_FORMAT_NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
return transaction(async client => {
|
||||
const target = await assertTargetReferences(client, auth, body);
|
||||
const normalizedItems = rawItems.map((raw, index) => {
|
||||
const rowNo = index + 1;
|
||||
const { normalized, issues } = normalizeQuestion(raw, rowNo);
|
||||
return {
|
||||
rowNo,
|
||||
status: issues.some(issue => issue.severity === 'error') ? 'invalid' as const : 'valid' as const,
|
||||
externalId: normalized?.legacyId || null,
|
||||
source: raw,
|
||||
normalized,
|
||||
issues,
|
||||
};
|
||||
});
|
||||
|
||||
const issues = normalizedItems.flatMap(item => item.issues);
|
||||
const errorCount = issues.filter(issue => issue.severity === 'error').length;
|
||||
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
|
||||
const validCount = normalizedItems.filter(item => item.status === 'valid').length;
|
||||
const rawPayload = JSON.stringify(rawItems);
|
||||
const normalizedPayload = JSON.stringify(normalizedItems.map(item => item.normalized).filter(Boolean));
|
||||
|
||||
const jobResult = await client.query(
|
||||
`
|
||||
insert into public.content_import_jobs (
|
||||
tenant_id, created_by, import_type, source_format, status,
|
||||
source_name, source_hash, target_region_id, target_subject_id,
|
||||
target_category_id, target_node_id, target_question_bank_id,
|
||||
dry_run, total_count, valid_count, error_count, warning_count,
|
||||
summary, raw_payload, normalized_payload
|
||||
)
|
||||
values (
|
||||
$1, $2, 'questions', $3, 'preview',
|
||||
$4, $5, $6::uuid, $7::uuid,
|
||||
$8::uuid, $9::uuid, $10::uuid,
|
||||
true, $11, $12, $13, $14,
|
||||
$15::jsonb, $16::jsonb, $17::jsonb
|
||||
)
|
||||
returning id, status, total_count as "totalCount", valid_count as "validCount",
|
||||
error_count as "errorCount", warning_count as "warningCount"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
sourceFormat,
|
||||
sourceName,
|
||||
contentHash(rawItems),
|
||||
target.regionId,
|
||||
target.subjectId,
|
||||
target.categoryId,
|
||||
target.nodeId,
|
||||
target.questionBankId,
|
||||
rawItems.length,
|
||||
validCount,
|
||||
errorCount,
|
||||
warningCount,
|
||||
JSON.stringify({ target, generatedAt: new Date().toISOString() }),
|
||||
rawPayload,
|
||||
normalizedPayload,
|
||||
],
|
||||
);
|
||||
|
||||
const job = jobResult.rows[0];
|
||||
const responseItems: PreviewResult['items'] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemResult = await client.query(
|
||||
`
|
||||
insert into public.content_import_items (
|
||||
tenant_id, job_id, row_no, external_id, status, target_type,
|
||||
source_payload, normalized_payload, content_hash, issues_count
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'question', $6::jsonb, $7::jsonb, $8, $9)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
item.rowNo,
|
||||
item.externalId,
|
||||
item.status,
|
||||
JSON.stringify(item.source),
|
||||
JSON.stringify(item.normalized || {}),
|
||||
item.normalized?.sourceHash || contentHash(item.source),
|
||||
item.issues.length,
|
||||
],
|
||||
);
|
||||
|
||||
const itemId = itemResult.rows[0].id;
|
||||
for (const issue of item.issues) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.content_import_issues (
|
||||
tenant_id, job_id, item_id, row_no, severity, code,
|
||||
field_path, message, details
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
itemId,
|
||||
issue.rowNo,
|
||||
issue.severity,
|
||||
issue.code,
|
||||
issue.fieldPath,
|
||||
issue.message,
|
||||
JSON.stringify(issue.details || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
responseItems.push({
|
||||
rowNo: item.rowNo,
|
||||
status: item.status,
|
||||
externalId: item.externalId,
|
||||
normalized: item.normalized,
|
||||
issues: item.issues,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.import.previewed', 'content_import_job', $3, $4::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, job.id, JSON.stringify({ importType: 'questions', total: rawItems.length, errorCount, warningCount })],
|
||||
);
|
||||
|
||||
return {
|
||||
job,
|
||||
items: responseItems,
|
||||
issues,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
total_count: number;
|
||||
valid_count: number;
|
||||
error_count: number;
|
||||
warning_count: number;
|
||||
target_region_id: string | null;
|
||||
target_subject_id: string;
|
||||
target_category_id: string;
|
||||
target_node_id: string | null;
|
||||
target_question_bank_id: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_subject_id, target_category_id,
|
||||
target_node_id, target_question_bank_id
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = 'questions'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
|
||||
const job = result.rows[0];
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async function currentVersionHash(client: pg.PoolClient, questionId: string) {
|
||||
const result = await client.query<{ source_hash: string | null }>(
|
||||
`
|
||||
select v.source_hash
|
||||
from public.questions q
|
||||
join public.question_versions v on v.id = q.current_version_id
|
||||
where q.id = $1
|
||||
limit 1
|
||||
`,
|
||||
[questionId],
|
||||
);
|
||||
return result.rows[0]?.source_hash || null;
|
||||
}
|
||||
|
||||
async function importOneQuestion(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantContentAuth,
|
||||
job: {
|
||||
id: string;
|
||||
target_subject_id: string;
|
||||
target_category_id: string;
|
||||
target_node_id: string | null;
|
||||
target_question_bank_id: string | null;
|
||||
},
|
||||
item: {
|
||||
id: string;
|
||||
row_no: number;
|
||||
normalized_payload: NormalizedQuestion;
|
||||
},
|
||||
) {
|
||||
const normalized = item.normalized_payload;
|
||||
const legacyId = normalized.legacyId || `content-import:${job.id}:${item.row_no}`;
|
||||
const existing = await client.query<{ id: string }>(
|
||||
'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1 for update',
|
||||
[auth.tenantId, legacyId],
|
||||
);
|
||||
const existingQuestion = existing.rows[0];
|
||||
|
||||
let questionId = existingQuestion?.id || '';
|
||||
if (!existingQuestion) {
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.questions (
|
||||
tenant_id, question_bank_id, subject_id, category_id, node_id,
|
||||
legacy_id, type, type_label, difficulty, tags, media_url, status
|
||||
)
|
||||
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.target_question_bank_id,
|
||||
job.target_subject_id,
|
||||
job.target_category_id,
|
||||
job.target_node_id,
|
||||
legacyId,
|
||||
normalized.type,
|
||||
normalized.typeLabel,
|
||||
normalized.difficulty,
|
||||
JSON.stringify(normalized.tags),
|
||||
normalized.mediaUrl,
|
||||
],
|
||||
);
|
||||
questionId = inserted.rows[0].id;
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = $3::uuid,
|
||||
subject_id = $4::uuid,
|
||||
category_id = $5::uuid,
|
||||
node_id = $6::uuid,
|
||||
type = $7,
|
||||
type_label = $8,
|
||||
difficulty = $9,
|
||||
tags = $10::jsonb,
|
||||
media_url = $11,
|
||||
status = 'published',
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
job.target_question_bank_id,
|
||||
job.target_subject_id,
|
||||
job.target_category_id,
|
||||
job.target_node_id,
|
||||
normalized.type,
|
||||
normalized.typeLabel,
|
||||
normalized.difficulty,
|
||||
JSON.stringify(normalized.tags),
|
||||
normalized.mediaUrl,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
|
||||
if (previousHash && previousHash === normalized.sourceHash) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_items
|
||||
set status = 'skipped', target_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, item.id, questionId],
|
||||
);
|
||||
return 'skipped' as const;
|
||||
}
|
||||
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[questionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
|
||||
const version = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nextVersionNo,
|
||||
normalized.content,
|
||||
JSON.stringify(normalized.options),
|
||||
normalized.correctOptionIndex,
|
||||
JSON.stringify(normalized.correctOptionIndices),
|
||||
normalized.answerText,
|
||||
normalized.explanation,
|
||||
JSON.stringify(normalized.subQuestions),
|
||||
normalized.codeLang,
|
||||
normalized.codeTemplate,
|
||||
normalized.sourceHash,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, questionId, version.rows[0].id],
|
||||
);
|
||||
|
||||
const status = existingQuestion ? 'updated' : 'inserted';
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_items
|
||||
set status = $3, target_id = $4, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, item.id, status, questionId],
|
||||
);
|
||||
|
||||
return status as 'inserted' | 'updated';
|
||||
}
|
||||
|
||||
export async function previewQuestionsImportRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
return createQuestionPreviewJob(auth, body);
|
||||
}
|
||||
|
||||
export async function importQuestionsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const allowPartial = boolValue(body.allowPartial, false);
|
||||
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
|
||||
|
||||
const createdPreview = jobId ? null : await createQuestionPreviewJob(auth, body);
|
||||
const finalJobId = jobId || createdPreview?.job.id || '';
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const job = await loadPreviewJob(client, auth, finalJobId);
|
||||
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
idempotent: true,
|
||||
insertedCount: 0,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (job.error_count > 0 && !allowPartial) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'rejected', error_message = 'Preview contains validation errors', updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
row_no: number;
|
||||
normalized_payload: NormalizedQuestion;
|
||||
}>(
|
||||
`
|
||||
select id, row_no, normalized_payload
|
||||
from public.content_import_items
|
||||
where tenant_id = $1 and job_id = $2 and status = 'valid'
|
||||
order by row_no asc
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
for (const item of itemResult.rows) {
|
||||
const status = await importOneQuestion(client, auth, job, item);
|
||||
if (status === 'inserted') insertedCount += 1;
|
||||
if (status === 'updated') updatedCount += 1;
|
||||
if (status === 'skipped') skippedCount += 1;
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
inserted_count = $4,
|
||||
updated_count = $5,
|
||||
skipped_count = $6,
|
||||
summary = coalesce(summary, '{}'::jsonb) || $7::jsonb,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
job.id,
|
||||
finalStatus,
|
||||
insertedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.import.questions.completed', 'content_import_job', $3, $4::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, job.id, JSON.stringify({ insertedCount, updatedCount, skippedCount, allowPartial })],
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: finalStatus,
|
||||
insertedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
errorCount: job.error_count,
|
||||
warningCount: job.warning_count,
|
||||
};
|
||||
});
|
||||
|
||||
return { item: result, preview: createdPreview };
|
||||
}
|
||||
|
||||
export async function importJobsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const importType = stringParam(ctx, 'importType');
|
||||
const status = stringParam(ctx, 'status');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (importType) {
|
||||
params.push(importType);
|
||||
filters.push(`import_type = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, import_type as "importType", source_format as "sourceFormat",
|
||||
status, source_name as "sourceName", source_hash as "sourceHash",
|
||||
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
|
||||
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
dry_run as "dryRun", total_count as "totalCount",
|
||||
valid_count as "validCount", error_count as "errorCount",
|
||||
warning_count as "warningCount", inserted_count as "insertedCount",
|
||||
updated_count as "updatedCount", skipped_count as "skippedCount",
|
||||
summary, error_message as "errorMessage",
|
||||
started_at as "startedAt", finished_at as "finishedAt",
|
||||
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.content_import_jobs
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function importIssuesRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
const job = await queryOne<{ id: string }>(
|
||||
'select id from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select i.id, i.row_no as "rowNo", i.severity, i.code,
|
||||
i.field_path as "fieldPath", i.message, i.details,
|
||||
item.external_id as "externalId", item.status as "itemStatus",
|
||||
i.created_at as "createdAt"
|
||||
from public.content_import_issues i
|
||||
left join public.content_import_items item on item.id = i.item_id
|
||||
where i.tenant_id = $1 and i.job_id = $2
|
||||
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, jobId, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
72
apps/api/src/features/tenant-content/index.ts
Normal file
72
apps/api/src/features/tenant-content/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
assetsAdminRoute,
|
||||
signAssetDownloadAdminRoute,
|
||||
signAssetUploadRoute,
|
||||
upsertAssetRoute,
|
||||
} from './assets.js';
|
||||
import {
|
||||
importIssuesRoute,
|
||||
importJobsRoute,
|
||||
importQuestionsRoute,
|
||||
previewQuestionsImportRoute,
|
||||
} from './imports.js';
|
||||
import {
|
||||
bindQuestionVideoRoute,
|
||||
createQuestionRoute,
|
||||
handbookChaptersAdminRoute,
|
||||
handbookEntriesAdminRoute,
|
||||
handbookSubjectsAdminRoute,
|
||||
scorelineFieldsAdminRoute,
|
||||
scorelineMajorsAdminRoute,
|
||||
scorelineRecordsAdminRoute,
|
||||
scorelineSchoolsAdminRoute,
|
||||
updateQuestionRoute,
|
||||
upsertHandbookChapterRoute,
|
||||
upsertHandbookEntryRoute,
|
||||
upsertHandbookSubjectRoute,
|
||||
upsertScorelineFieldRoute,
|
||||
upsertScorelineMajorRoute,
|
||||
upsertScorelineRecordRoute,
|
||||
upsertScorelineSchoolRoute,
|
||||
upsertVideoRoute,
|
||||
upsertVocabularyUnitRoute,
|
||||
upsertVocabularyWordRoute,
|
||||
videosAdminRoute,
|
||||
vocabularyUnitsAdminRoute,
|
||||
vocabularyWordsAdminRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/questions', createQuestionRoute],
|
||||
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
|
||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
|
||||
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
|
||||
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
|
||||
['GET', '/api/tenant-content/imports', importJobsRoute],
|
||||
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
|
||||
['GET', '/api/tenant-content/videos', videosAdminRoute],
|
||||
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
|
||||
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
|
||||
['GET', '/api/tenant-content/scoreline/schools', scorelineSchoolsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/schools', upsertScorelineSchoolRoute],
|
||||
['GET', '/api/tenant-content/scoreline/majors', scorelineMajorsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/majors', upsertScorelineMajorRoute],
|
||||
['GET', '/api/tenant-content/scoreline/fields', scorelineFieldsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/fields', upsertScorelineFieldRoute],
|
||||
['GET', '/api/tenant-content/scoreline/records', scorelineRecordsAdminRoute],
|
||||
['PUT', '/api/tenant-content/scoreline/records', upsertScorelineRecordRoute],
|
||||
['GET', '/api/tenant-content/vocabulary-units', vocabularyUnitsAdminRoute],
|
||||
['PUT', '/api/tenant-content/vocabulary-units', upsertVocabularyUnitRoute],
|
||||
['GET', '/api/tenant-content/vocabulary-words', vocabularyWordsAdminRoute],
|
||||
['PUT', '/api/tenant-content/vocabulary-words', upsertVocabularyWordRoute],
|
||||
['GET', '/api/tenant-content/handbook-subjects', handbookSubjectsAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-subjects', upsertHandbookSubjectRoute],
|
||||
['GET', '/api/tenant-content/handbook-chapters', handbookChaptersAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-chapters', upsertHandbookChapterRoute],
|
||||
['GET', '/api/tenant-content/handbook-entries', handbookEntriesAdminRoute],
|
||||
['PUT', '/api/tenant-content/handbook-entries', upsertHandbookEntryRoute],
|
||||
];
|
||||
820
apps/api/src/features/tenant-content/routes.ts
Normal file
820
apps/api/src/features/tenant-content/routes.ts
Normal file
@@ -0,0 +1,820 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { requireTenantContentEditor } from './auth.js';
|
||||
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js';
|
||||
|
||||
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
|
||||
|
||||
export async function createQuestionRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const questionResult = await client.query(
|
||||
`
|
||||
insert into public.questions (
|
||||
tenant_id, question_bank_id, subject_id, category_id, node_id,
|
||||
legacy_id, type, type_label, difficulty, tags, media_url, status
|
||||
)
|
||||
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
|
||||
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
|
||||
status, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.questionBankId),
|
||||
nullableString(body.subjectId),
|
||||
nullableString(body.categoryId),
|
||||
nullableString(body.nodeId),
|
||||
nullableString(body.legacyId),
|
||||
optionalString(body, 'type') || 'choice',
|
||||
optionalString(body, 'typeLabel') || null,
|
||||
intValue(body.difficulty, 1),
|
||||
jsonArrayValue(body.tags),
|
||||
nullableString(body.mediaUrl),
|
||||
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
|
||||
],
|
||||
);
|
||||
const question = questionResult.rows[0];
|
||||
|
||||
const versionResult = await client.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13)
|
||||
returning id, version_no as "versionNo", content, options,
|
||||
correct_option_index as "correctOptionIndex",
|
||||
correct_option_indices as "correctOptionIndices",
|
||||
answer_text as "answerText", explanation, sub_questions as "subQuestions",
|
||||
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
question.id,
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.options),
|
||||
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
|
||||
jsonArrayValue(body.correctOptionIndices),
|
||||
nullableString(body.answerText),
|
||||
nullableString(body.explanation),
|
||||
jsonArrayValue(body.subQuestions),
|
||||
nullableString(body.codeLang),
|
||||
nullableString(body.codeTemplate),
|
||||
nullableString(body.sourceHash),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, question.id, versionResult.rows[0].id],
|
||||
);
|
||||
|
||||
return { ...question, currentVersion: versionResult.rows[0] };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateQuestionRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const createVersion = body.createVersion === true;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const questionResult = await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = coalesce($3::uuid, question_bank_id),
|
||||
subject_id = coalesce($4::uuid, subject_id),
|
||||
category_id = coalesce($5::uuid, category_id),
|
||||
node_id = coalesce($6::uuid, node_id),
|
||||
type = coalesce(nullif($7, ''), type),
|
||||
type_label = coalesce(nullif($8, ''), type_label),
|
||||
difficulty = coalesce($9, difficulty),
|
||||
tags = case when $10::boolean then $11::jsonb else tags end,
|
||||
media_url = coalesce(nullif($12, ''), media_url),
|
||||
status = coalesce(nullif($13, ''), status),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
|
||||
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nullableString(body.questionBankId),
|
||||
nullableString(body.subjectId),
|
||||
nullableString(body.categoryId),
|
||||
nullableString(body.nodeId),
|
||||
optionalString(body, 'type'),
|
||||
optionalString(body, 'typeLabel'),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
Object.hasOwn(body, 'tags'),
|
||||
jsonArrayValue(body.tags),
|
||||
nullableString(body.mediaUrl),
|
||||
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
|
||||
],
|
||||
);
|
||||
|
||||
const question = questionResult.rows[0];
|
||||
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
|
||||
if (!createVersion) return question;
|
||||
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[questionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
|
||||
const versionResult = await client.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template, source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id, version_no as "versionNo", content, options,
|
||||
correct_option_index as "correctOptionIndex",
|
||||
correct_option_indices as "correctOptionIndices",
|
||||
answer_text as "answerText", explanation, sub_questions as "subQuestions",
|
||||
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
nextVersionNo,
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.options),
|
||||
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
|
||||
jsonArrayValue(body.correctOptionIndices),
|
||||
nullableString(body.answerText),
|
||||
nullableString(body.explanation),
|
||||
jsonArrayValue(body.subQuestions),
|
||||
nullableString(body.codeLang),
|
||||
nullableString(body.codeTemplate),
|
||||
nullableString(body.sourceHash),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, questionId, versionResult.rows[0].id],
|
||||
);
|
||||
|
||||
return { ...question, currentVersion: versionResult.rows[0] };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function videosAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", title, description, video_url as "videoUrl",
|
||||
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
|
||||
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
|
||||
subject_id as "subjectId", difficulty, sort_order as "order",
|
||||
is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.video_explanations
|
||||
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
|
||||
order by sort_order asc, created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, subjectId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVideoRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const id = nullableString(body.id);
|
||||
const title = requiredString(body, 'title');
|
||||
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.video_explanations (
|
||||
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
|
||||
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
|
||||
sort_order, is_active
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7,
|
||||
$8, $9::jsonb, $10, $11::uuid, $12, $13, $14
|
||||
)
|
||||
on conflict (id)
|
||||
do update set title = excluded.title,
|
||||
description = excluded.description,
|
||||
video_url = excluded.video_url,
|
||||
thumbnail_url = excluded.thumbnail_url,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
knowledge_tags = excluded.knowledge_tags,
|
||||
is_general = excluded.is_general,
|
||||
subject_id = excluded.subject_id,
|
||||
difficulty = excluded.difficulty,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, title, description, video_url as "videoUrl",
|
||||
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
|
||||
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
|
||||
subject_id as "subjectId", difficulty, sort_order as "order",
|
||||
is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
id,
|
||||
auth.tenantId,
|
||||
nullableString(body.legacyId),
|
||||
title,
|
||||
nullableString(body.description),
|
||||
nullableString(body.videoUrl),
|
||||
nullableString(body.thumbnailUrl),
|
||||
body.duration === undefined ? null : intValue(body.duration, 0),
|
||||
jsonArrayValue(body.knowledgeTags),
|
||||
boolValue(body.isGeneral, false),
|
||||
nullableString(body.subjectId),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function bindQuestionVideoRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const videoId = requiredString(body, 'videoId');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]);
|
||||
if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]);
|
||||
if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.question_videos (
|
||||
tenant_id, question_id, video_id, legacy_id, video_type, sort_order
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, question_id as "questionId", video_id as "videoId",
|
||||
video_type as "videoType", sort_order as "order", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
videoId,
|
||||
nullableString(body.legacyId),
|
||||
optionalString(body, 'videoType') || 'specific',
|
||||
intValue(body.order, 0),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [
|
||||
auth.tenantId,
|
||||
questionId,
|
||||
]);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineSchoolsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, short_name as "shortName", type,
|
||||
is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_schools
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
[auth.tenantId, regionId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineSchoolRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
short_name = excluded.short_name,
|
||||
type = excluded.type,
|
||||
is_hot = excluded.is_hot,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, short_name as "shortName",
|
||||
type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.shortName),
|
||||
nullableString(body.type),
|
||||
boolValue(body.isHot, false),
|
||||
intValue(body.order, 0),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineMajorsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId", name,
|
||||
sort_order as "order", has_restriction as "hasRestriction",
|
||||
restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_majors
|
||||
where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid)
|
||||
order by sort_order asc, name asc
|
||||
`,
|
||||
[auth.tenantId, schoolId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineMajorRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_majors (
|
||||
id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
name = excluded.name,
|
||||
sort_order = excluded.sort_order,
|
||||
has_restriction = excluded.has_restriction,
|
||||
restriction_desc = excluded.restriction_desc,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", school_id as "schoolId", name,
|
||||
sort_order as "order", has_restriction as "hasRestriction",
|
||||
restriction_desc as "restrictionDesc", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
requiredString(body, 'schoolId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.hasRestriction, false),
|
||||
nullableString(body.restrictionDesc),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineFieldsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", field_key as "fieldKey",
|
||||
field_name as "fieldName", field_type as "fieldType", unit,
|
||||
is_filter as "isFilter", is_required as "isRequired",
|
||||
is_visible as "isVisible", is_trend as "isTrend",
|
||||
options, placeholder, description, sort_order as "sortOrder"
|
||||
from public.scoreline_fields
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by sort_order asc
|
||||
`,
|
||||
[auth.tenantId, regionId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineFieldRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_fields (
|
||||
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
|
||||
unit, is_filter, is_required, is_visible, is_trend, options,
|
||||
placeholder, description, sort_order
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16
|
||||
)
|
||||
on conflict (tenant_id, region_id, field_key)
|
||||
do update set field_name = excluded.field_name,
|
||||
field_type = excluded.field_type,
|
||||
unit = excluded.unit,
|
||||
is_filter = excluded.is_filter,
|
||||
is_required = excluded.is_required,
|
||||
is_visible = excluded.is_visible,
|
||||
is_trend = excluded.is_trend,
|
||||
options = excluded.options,
|
||||
placeholder = excluded.placeholder,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", field_key as "fieldKey",
|
||||
field_name as "fieldName", field_type as "fieldType",
|
||||
unit, is_filter as "isFilter", is_required as "isRequired",
|
||||
is_visible as "isVisible", is_trend as "isTrend",
|
||||
options, placeholder, description, sort_order as "sortOrder",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'fieldKey'),
|
||||
requiredString(body, 'fieldName'),
|
||||
optionalString(body, 'fieldType') || 'number',
|
||||
nullableString(body.unit),
|
||||
boolValue(body.isFilter, false),
|
||||
boolValue(body.isRequired, false),
|
||||
boolValue(body.isVisible, true),
|
||||
boolValue(body.isTrend, false),
|
||||
jsonArrayValue(body.options),
|
||||
nullableString(body.placeholder),
|
||||
nullableString(body.description),
|
||||
intValue(body.sortOrder, 0),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scorelineRecordsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.scoreline_records
|
||||
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
|
||||
order by year desc, school_name asc, major_name asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, regionId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertScorelineRecordRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.scoreline_records (
|
||||
id, tenant_id, region_id, school_id, major_id, legacy_id,
|
||||
year, school_name, major_name, field_values
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
major_id = excluded.major_id,
|
||||
year = excluded.year,
|
||||
school_name = excluded.school_name,
|
||||
major_name = excluded.major_name,
|
||||
field_values = excluded.field_values,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", school_id as "schoolId",
|
||||
major_id as "majorId", year, school_name as "schoolName",
|
||||
major_name as "majorName", field_values as "fieldValues",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.schoolId),
|
||||
nullableString(body.majorId),
|
||||
nullableString(body.legacyId),
|
||||
intValue(body.year, new Date().getFullYear()),
|
||||
nullableString(body.schoolName),
|
||||
nullableString(body.majorName),
|
||||
jsonObjectValue(body.fieldValues),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_units
|
||||
where tenant_id = $1
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_units (
|
||||
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
word_count = excluded.word_count,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, description, word_count as "wordCount",
|
||||
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.description),
|
||||
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
const items = await query(
|
||||
`
|
||||
select id, unit_id as "unitId", word, phonetic, meaning, example,
|
||||
example_translation as "exampleTranslation", difficulty, tags,
|
||||
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_words
|
||||
where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid)
|
||||
order by sort_order asc, word asc
|
||||
limit $3
|
||||
`,
|
||||
[auth.tenantId, unitId || null, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_words (
|
||||
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
|
||||
example, example_translation, difficulty, tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
|
||||
on conflict (id)
|
||||
do update set unit_id = excluded.unit_id,
|
||||
word = excluded.word,
|
||||
phonetic = excluded.phonetic,
|
||||
meaning = excluded.meaning,
|
||||
example = excluded.example,
|
||||
example_translation = excluded.example_translation,
|
||||
difficulty = excluded.difficulty,
|
||||
tags = excluded.tags,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, unit_id as "unitId", word, phonetic, meaning,
|
||||
example, example_translation as "exampleTranslation",
|
||||
difficulty, tags, sort_order as "order", is_active as "isActive",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.unitId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'word'),
|
||||
nullableString(body.phonetic),
|
||||
nullableString(body.meaning),
|
||||
nullableString(body.example),
|
||||
nullableString(body.exampleTranslation),
|
||||
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
|
||||
jsonArrayValue(body.tags),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, region_id as "regionId", name, type, icon, color,
|
||||
description, sort_order as "order", is_active as "isActive", metadata
|
||||
from public.handbook_subjects
|
||||
where tenant_id = $1
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_subjects (
|
||||
id, tenant_id, region_id, legacy_id, name, type, icon, color,
|
||||
description, sort_order, is_active, metadata
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
icon = excluded.icon,
|
||||
color = excluded.color,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, region_id as "regionId", name, type, icon, color,
|
||||
description, sort_order as "order", is_active as "isActive",
|
||||
metadata, updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.type),
|
||||
nullableString(body.icon),
|
||||
nullableString(body.color),
|
||||
nullableString(body.description),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
jsonObjectValue(body.metadata),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookChaptersAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, subject_id as "subjectId", name, description,
|
||||
sort_order as "order", is_active as "isActive"
|
||||
from public.handbook_chapters
|
||||
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId, subjectId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookChapterRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_chapters (
|
||||
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
|
||||
on conflict (id)
|
||||
do update set subject_id = excluded.subject_id,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, subject_id as "subjectId", name, description,
|
||||
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
requiredString(body, 'subjectId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.description),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function handbookEntriesAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const chapterId = stringParam(ctx, 'chapterId');
|
||||
const items = await query(
|
||||
`
|
||||
select id, chapter_id as "chapterId", title, summary, content,
|
||||
tags, sort_order as "order", is_active as "isActive"
|
||||
from public.handbook_entries
|
||||
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
|
||||
order by sort_order asc, created_at asc
|
||||
`,
|
||||
[auth.tenantId, chapterId || null],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertHandbookEntryRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_entries (
|
||||
id, tenant_id, chapter_id, legacy_id, title, summary, content,
|
||||
tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
on conflict (id)
|
||||
do update set chapter_id = excluded.chapter_id,
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
content = excluded.content,
|
||||
tags = excluded.tags,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
returning id, chapter_id as "chapterId", title, summary, content,
|
||||
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
nullableString(body.id),
|
||||
auth.tenantId,
|
||||
requiredString(body, 'chapterId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'title'),
|
||||
nullableString(body.summary),
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.tags),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
return { item };
|
||||
}
|
||||
32
apps/api/src/features/tenant-content/utils.ts
Normal file
32
apps/api/src/features/tenant-content/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function jsonObjectValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
}
|
||||
|
||||
export function jsonArrayValue(value: unknown) {
|
||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
||||
}
|
||||
|
||||
export function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function intValue(value: unknown, fallback: number) {
|
||||
const numberValue = Number(value ?? fallback);
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
|
||||
}
|
||||
|
||||
export function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function optionalStatus(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid status: ${candidate}`, 'INVALID_STATUS');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
6
apps/api/src/features/tenant/index.ts
Normal file
6
apps/api/src/features/tenant/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { resolveTenantRoute } from './routes.js';
|
||||
|
||||
export const tenantRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant/resolve', resolveTenantRoute],
|
||||
];
|
||||
108
apps/api/src/features/tenant/routes.ts
Normal file
108
apps/api/src/features/tenant/routes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { config } from '../../core/config.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { getHeader, type RequestContext } from '../../core/http.js';
|
||||
|
||||
interface TenantResolveRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
host: string | null;
|
||||
brand_name: string | null;
|
||||
short_name: string | null;
|
||||
slogan: string | null;
|
||||
logo_url: string | null;
|
||||
favicon_url: string | null;
|
||||
service_wechat: string | null;
|
||||
service_account_name: string | null;
|
||||
theme: Record<string, unknown>;
|
||||
feature_flags: Record<string, unknown>;
|
||||
admin_feature_flags: Record<string, unknown>;
|
||||
public_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string) {
|
||||
return host.split(':')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
const hostParam = ctx.url.searchParams.get('host') || '';
|
||||
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
|
||||
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
|
||||
|
||||
const row = tenantCode
|
||||
? await queryOne<TenantResolveRow>(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
null::text as host,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
from public.tenants t
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_settings s on s.tenant_id = t.id
|
||||
where t.slug = $1 and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantCode],
|
||||
)
|
||||
: await queryOne<TenantResolveRow>(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
d.host::text,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
from public.tenant_domains d
|
||||
join public.tenants t on t.id = d.tenant_id
|
||||
left join public.tenant_branding b on b.tenant_id = t.id
|
||||
left join public.tenant_settings s on s.tenant_id = t.id
|
||||
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[requestHost || 'localhost'],
|
||||
);
|
||||
|
||||
if (!row && requestHost !== 'localhost') {
|
||||
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
|
||||
return resolveTenantRoute(ctx);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
found: false,
|
||||
message: 'Tenant not found',
|
||||
lookup: { host: requestHost, tenantCode: tenantCode || null },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
tenant: {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
mode: row.mode,
|
||||
host: row.host,
|
||||
},
|
||||
branding: {
|
||||
brandName: row.brand_name || row.name,
|
||||
shortName: row.short_name || row.name,
|
||||
slogan: row.slogan || '',
|
||||
logoUrl: row.logo_url || '',
|
||||
faviconUrl: row.favicon_url || '',
|
||||
serviceWechat: row.service_wechat || '',
|
||||
serviceAccountName: row.service_account_name || '',
|
||||
theme: row.theme || {},
|
||||
},
|
||||
features: row.feature_flags || {},
|
||||
adminFeatures: row.admin_feature_flags || {},
|
||||
publicConfig: row.public_config || {},
|
||||
};
|
||||
}
|
||||
8
apps/api/src/features/video/index.ts
Normal file
8
apps/api/src/features/video/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js';
|
||||
|
||||
export const videoRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/questions/videos', questionVideosRoute],
|
||||
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
|
||||
['GET', '/api/videos/search', videoSearchRoute],
|
||||
];
|
||||
111
apps/api/src/features/video/routes.ts
Normal file
111
apps/api/src/features/video/routes.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
|
||||
import { query } from '../../core/db.js';
|
||||
|
||||
interface QuestionVideoRow {
|
||||
questionId: string;
|
||||
videoType: string;
|
||||
order: number;
|
||||
id: string;
|
||||
legacyId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
videoUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
duration: number | null;
|
||||
knowledgeTags: unknown[];
|
||||
isGeneral: boolean;
|
||||
subjectId: string | null;
|
||||
difficulty: number | null;
|
||||
}
|
||||
|
||||
function videoSelectSql() {
|
||||
return `
|
||||
select qv.question_id as "questionId", qv.video_type as "videoType",
|
||||
qv.sort_order as "order",
|
||||
v.id, v.legacy_id as "legacyId", v.title, v.description,
|
||||
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
|
||||
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
|
||||
v.is_general as "isGeneral", v.subject_id as "subjectId",
|
||||
v.difficulty
|
||||
from public.question_videos qv
|
||||
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function questionVideosRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const questionId = stringParam(ctx, 'questionId');
|
||||
if (!questionId) {
|
||||
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
|
||||
}
|
||||
|
||||
const videos = await query<QuestionVideoRow>(
|
||||
`
|
||||
${videoSelectSql()}
|
||||
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
|
||||
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
|
||||
`,
|
||||
[tenantId, questionId],
|
||||
);
|
||||
|
||||
return { videos, total: videos.length };
|
||||
}
|
||||
|
||||
export async function questionVideosBatchRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
|
||||
|
||||
if (!questionIds.length) {
|
||||
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
|
||||
}
|
||||
|
||||
const rows = await query<QuestionVideoRow>(
|
||||
`
|
||||
${videoSelectSql()}
|
||||
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
|
||||
order by qv.question_id, qv.sort_order asc, v.sort_order asc
|
||||
`,
|
||||
[tenantId, questionIds],
|
||||
);
|
||||
|
||||
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
|
||||
for (const row of rows) {
|
||||
data[row.questionId] ||= { hasVideo: true, videos: [] };
|
||||
data[row.questionId].videos.push(row);
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
export async function videoSearchRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const tags = stringParam(ctx, 'tags')
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const videos = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", title, description,
|
||||
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
|
||||
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
|
||||
is_general as "isGeneral", subject_id as "subjectId",
|
||||
difficulty, sort_order as "order", created_at as "createdAt"
|
||||
from public.video_explanations
|
||||
where tenant_id = $1
|
||||
and is_active = true
|
||||
and is_general = true
|
||||
and ($2::uuid is null or subject_id = $2::uuid)
|
||||
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
|
||||
order by sort_order asc, created_at desc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, subjectId || null, tags, limit],
|
||||
);
|
||||
|
||||
return { videos, total: videos.length };
|
||||
}
|
||||
48
apps/api/src/server.ts
Normal file
48
apps/api/src/server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import http from 'node:http';
|
||||
import { config } from './core/config.js';
|
||||
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
|
||||
import { createRouter } from './core/router.js';
|
||||
|
||||
const routes = createRouter();
|
||||
|
||||
function resolveHandler(method: string | undefined, url: URL) {
|
||||
const exact = routes.get(routeKey(method, url.pathname));
|
||||
if (exact) return exact;
|
||||
|
||||
const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/);
|
||||
if (method === 'GET' && questionVideosMatch?.[1]) {
|
||||
url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1]));
|
||||
return routes.get(routeKey(method, '/api/questions/videos'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
applyCors(req, res);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const handler = resolveHandler(req.method, url);
|
||||
|
||||
if (!handler) {
|
||||
sendJson(res, 404, { error: 'Not found', path: url.pathname });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler({ req, res, url });
|
||||
sendJson(res, 200, result);
|
||||
} catch (error) {
|
||||
const { statusCode, body } = publicErrorBody(error);
|
||||
sendJson(res, statusCode, body);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
|
||||
});
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "../..",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user