commit c3dd9be674cd67084c490de80f0ab5b50e583b5b Author: g1307092382 <1307092382@qq.com> Date: Wed Aug 12 14:35:12 2026 +0800 feat: complete V1 phases 0-7 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14c4d77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +**/node_modules +**/dist +coverage +.env +*.log +docs +*.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e95cecf --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +APP_ENV=development +API_PORT=3000 +DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3306/lehuo +TEST_DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3307/lehuo_test +REDIS_URL=redis://localhost:6379 +MYSQL_DATABASE=lehuo +MYSQL_USER=lehuo +MYSQL_PASSWORD=change-me-local-only +MYSQL_ROOT_PASSWORD=change-root-password-local-only +MINIO_ROOT_USER=lehuo-local +MINIO_ROOT_PASSWORD=change-minio-password-local-only +MINIO_ENDPOINT=http://localhost:9000 +MINIO_BUCKET=lehuo-local +WECHAT_APP_ID= +WECHAT_APP_SECRET= +WECHAT_TEMPLATE_APPLICATION_SUBMITTED= +WECHAT_TEMPLATE_APPLICATION_ACCEPTED= +WECHAT_TEMPLATE_SERVICE_ARRANGED= +WECHAT_TEMPLATE_SERVICE_COMPLETED= +WECHAT_TEMPLATE_FOLLOW_UP_PENDING= +WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT= +NOTIFICATION_WORKER_ENABLED=false +OBJECT_STORAGE_ENDPOINT=http://localhost:9000 +OBJECT_STORAGE_REGION=auto +OBJECT_STORAGE_BUCKET=lehuo-private +OBJECT_STORAGE_ACCESS_KEY= +OBJECT_STORAGE_SECRET_KEY= +OBJECT_STORAGE_FORCE_PATH_STYLE=true +COS_BUCKET= +COS_REGION= +COS_SECRET_ID= +COS_SECRET_KEY= +SESSION_SECRET= +SESSION_TOKEN_PEPPER= +AUDIT_IP_HASH_KEY= +FIELD_ENCRYPTION_KEY= +ADMIN_WEB_ORIGIN=http://localhost:5173 +ADMIN_COOKIE_SECURE=false +AUTH_SESSION_TTL_SECONDS=28800 +WECHAT_SESSION_TTL_SECONDS=7200 +BOOTSTRAP_ADMIN_USERNAME= +BOOTSTRAP_ADMIN_PASSWORD= +BOOTSTRAP_ADMIN_DISPLAY_NAME= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b42fdd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + mysql: + image: mysql:8.4.8 + env: + MYSQL_DATABASE: lehuo_test + MYSQL_USER: lehuo + MYSQL_PASSWORD: ci-test-only + MYSQL_ROOT_PASSWORD: ci-root-test-only + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci-root-test-only --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + APP_ENV: test + DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test + TEST_DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test + SESSION_TOKEN_PEPPER: ci-test-session-token-pepper-2026 + AUDIT_IP_HASH_KEY: ci-test-audit-ip-hash-key-2026 + FIELD_ENCRYPTION_KEY: CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws= + ADMIN_WEB_ORIGIN: http://localhost:5173 + ADMIN_COOKIE_SECURE: 'false' + WECHAT_APP_ID: ci-test-app-id + WECHAT_APP_SECRET: ci-test-app-secret + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.13.1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate formatting + run: pnpm format:check + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests + run: pnpm test:unit + + - name: Integration tests + run: pnpm test:integration + + - name: Build + run: pnpm build + + - name: Validate Docker Compose + run: docker compose --env-file .env.example config --quiet + + - name: Dependency audit + run: pnpm audit --registry=https://registry.npmjs.org --audit-level high diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..16565ad --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,34 @@ +name: Security +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '23 2 * * 1' +permissions: + contents: read +jobs: + dependency-and-secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 11.13.1 } + - uses: actions/setup-node@v4 + with: { node-version: 24, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level=high + - name: Reject committed private keys and common live secrets + shell: bash + run: | + if git grep -IEn 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|sk_live_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}' -- ':!pnpm-lock.yaml'; then + echo 'Potential secret detected'; exit 1 + fi + codeql: + runs-on: ubuntu-latest + permissions: { security-events: write, contents: read } + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: { languages: javascript-typescript } + - uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2171e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.log +.DS_Store +.idea/ +.vscode/ +apps/miniprogram/miniprogram_npm/ +apps/miniprogram/.miniprogram/ +apps/api/src/generated/prisma/ +minio-data/ +mysql-data/ +redis-data/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7aaaea2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +**/dist +**/node_modules +**/miniprogram_npm +coverage +pnpm-lock.yaml +docs +乐活公益小程序_Codex开发总文档_V1.0.md +docs/ui_reference.png +*.png diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6fc6d66 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af912ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS.md + +Before coding, read in order: + +1. `docs/00_README_FIRST.md` +2. `docs/01_PROJECT_CONSTITUTION.md` +3. `docs/10_TASK_STATE.md` +4. `docs/11_DECISIONS.md` +5. Task-related documents in `docs/` + +Rules: + +- Do not change the approved product scope, technology stack, database, or authentication approach without explicit approval. +- Do not remove existing features to simplify implementation or use mocks to claim production functionality is complete. +- Do not bypass authorization, resource policies, migrations, tests, audit requirements, or state-machine validation. +- Do not log tokens, full mobile numbers, identity documents, detailed addresses, child records, or beneficiary service descriptions. +- Do not expose sequential internal IDs; public resources use unpredictable public identifiers. +- Every business status change must be auditable, and critical writes must be transactionally consistent. +- Every schema change requires a migration; every API change must update shared contracts and API documentation. +- Prefer adapters around external providers; business services must not directly depend on vendor SDKs. +- Update `docs/10_TASK_STATE.md` after every task and preserve its history. +- Update `docs/11_DECISIONS.md` for architecture or product decisions; do not overturn accepted decisions because another approach seems newer. +- Run lint, typecheck, unit tests, integration tests, and build before marking work complete. diff --git a/ChatGPT Image 2026年8月10日 19_36_20.png b/ChatGPT Image 2026年8月10日 19_36_20.png new file mode 100644 index 0000000..0bd414a Binary files /dev/null and b/ChatGPT Image 2026年8月10日 19_36_20.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f41138 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# 乐活公益·暖心服务平台 + +V1.0 使用 TypeScript 全栈 monorepo,包含微信小程序、Web 管理后台、模块化单体 API 与共享契约。 + +## 环境要求 + +- Node.js 24 LTS +- pnpm 11 +- Docker 29+ 与 Docker Compose + +## 初始化 + +1. 复制 `.env.example` 为 `.env`,仅填写本地开发值,禁止提交真实密钥。 +2. 运行 `pnpm install`。 +3. 为 `SESSION_TOKEN_PEPPER`、`AUDIT_IP_HASH_KEY` 生成独立随机值,为 `FIELD_ENCRYPTION_KEY` 生成 32 字节随机值并以 Base64 保存;填写微信开发配置和精确的 `ADMIN_WEB_ORIGIN`。 +4. 运行 `docker compose up -d` 启动 MySQL、Redis、MinIO。 +5. 运行 `pnpm --filter @lehuo/api db:deploy` 执行 migration。 +6. 首次部署时通过环境变量提供 `BOOTSTRAP_ADMIN_USERNAME`、`BOOTSTRAP_ADMIN_PASSWORD`、`BOOTSTRAP_ADMIN_DISPLAY_NAME`,运行 `pnpm --filter @lehuo/api bootstrap:admin`;已有后台凭据时命令会拒绝重复初始化。 +7. 运行 `pnpm --filter @lehuo/api dev` 启动 API,健康检查地址为 `http://localhost:3000/health`,Swagger 地址为 `http://localhost:3000/api/docs`。 +8. 运行 `pnpm --filter @lehuo/admin-web dev` 启动管理后台。 +9. 配置小程序 API 地址后,使用微信开发者工具导入 `apps/miniprogram`。 + +后台会话使用 HttpOnly Cookie,不存入 localStorage;生产环境必须启用 HTTPS 并设置 `ADMIN_COOKIE_SECURE=true`。密钥和首次管理员密码不得提交到仓库。 + +## 质量命令 + +```bash +pnpm lint +pnpm typecheck +pnpm test:unit +pnpm test:integration +pnpm build +``` + +集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。 + +项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。 diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example new file mode 100644 index 0000000..99cbcd5 --- /dev/null +++ b/apps/admin-web/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3000 diff --git a/apps/admin-web/README.md b/apps/admin-web/README.md new file mode 100644 index 0000000..9b646c1 --- /dev/null +++ b/apps/admin-web/README.md @@ -0,0 +1,5 @@ +# 管理后台 + +Vue 3 + TypeScript + Vite。Phase 1 至 Phase 5 提供后台账号登录、服务申请与接单、服务对象、工单与记录、回访工作台,以及志愿者审核、公益项目、活动报名、签到和服务时长工作台。凭据由 HttpOnly Cookie 管理,不写入 localStorage;列表手机号默认脱敏,敏感详情查看记录审计。 + +复制 `.env.example` 为 `.env.local` 并配置 `VITE_API_BASE_URL`,然后运行 `pnpm --filter @lehuo/admin-web dev`。该地址必须与 API 的 `ADMIN_WEB_ORIGIN` CORS 配置配套。 diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 0000000..f2ef9bc --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,13 @@ + + + + + + + 乐活公益管理后台 + + +
+ + + diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 0000000..39715b8 --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@lehuo/admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm --filter @lehuo/contracts build && vite build", + "dev": "pnpm --filter @lehuo/contracts build && vite", + "test:unit": "pnpm --filter @lehuo/contracts build && vitest run", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@lehuo/contracts": "workspace:*", + "pinia": "^3.0.3", + "vue": "^3.5.20", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.3", + "vitest": "^3.2.4", + "vue-tsc": "^3.0.6" + } +} diff --git a/apps/admin-web/src/App.vue b/apps/admin-web/src/App.vue new file mode 100644 index 0000000..36efd85 --- /dev/null +++ b/apps/admin-web/src/App.vue @@ -0,0 +1,245 @@ + + + diff --git a/apps/admin-web/src/api/api-client.ts b/apps/admin-web/src/api/api-client.ts new file mode 100644 index 0000000..985d6a4 --- /dev/null +++ b/apps/admin-web/src/api/api-client.ts @@ -0,0 +1,42 @@ +import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined; + +export class ApiClientError extends Error { + constructor( + message: string, + readonly code: string, + readonly requestId: string | null, + ) { + super(message); + } +} + +export async function apiRequest(path: string, init: RequestInit = {}): Promise { + if (!API_BASE_URL) { + throw new ApiClientError('管理后台尚未配置服务地址', 'CONFIGURATION_ERROR', null); + } + let response: Response; + try { + response = await fetch(new URL(path, API_BASE_URL), { + ...init, + credentials: 'include', + headers: { + 'content-type': 'application/json', + ...init.headers, + }, + }); + } catch { + throw new ApiClientError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null); + } + const payload = (await response.json()) as ApiSuccessResponse | ApiErrorResponse; + if (!response.ok || !payload.success) { + const error = payload as ApiErrorResponse; + throw new ApiClientError( + error.error?.message || '请求失败,请稍后重试', + error.error?.code || 'UNKNOWN_ERROR', + error.requestId || response.headers.get('x-request-id'), + ); + } + return payload.data; +} diff --git a/apps/admin-web/src/api/case-management.ts b/apps/admin-web/src/api/case-management.ts new file mode 100644 index 0000000..dd530c2 --- /dev/null +++ b/apps/admin-web/src/api/case-management.ts @@ -0,0 +1,172 @@ +import type { + AcceptServiceApplicationRequest, + AcceptServiceApplicationResponse, + AssigneeOption, + BeneficiaryDetailResponse, + BeneficiaryTagOption, + ConfirmUploadResponse, + CompleteServiceCaseRequest, + CompleteServiceCaseResponse, + CreateServiceRecordRequest, + CreateUploadIntentRequest, + CreateUploadIntentResponse, + DownloadUrlResponse, + PaginatedBeneficiariesResponse, + PaginatedServiceCasesResponse, + ReplaceCaseAssigneesRequest, + ServiceCaseDetailResponse, + ServiceCategory, + ServiceCaseStatus, + ServiceRecordResponse, + TransitionServiceApplicationRequest, + UpdateBeneficiaryRequest, + UpdateServiceRecordRequest, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +function queryString(values: Record): string { + const params = new URLSearchParams(); + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== '') params.set(key, String(value)); + }); + return params.toString(); +} + +export function transitionServiceApplication( + publicId: string, + input: TransitionServiceApplicationRequest, +): Promise<{ status: string }> { + return apiRequest( + `/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/transition`, + { + method: 'POST', + body: JSON.stringify(input), + }, + ); +} + +export function acceptServiceApplication( + publicId: string, + input: AcceptServiceApplicationRequest, +): Promise { + return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/accept`, { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export function fetchBeneficiaryTags(): Promise { + return apiRequest('/api/v1/admin/beneficiaries/tags'); +} + +export function fetchBeneficiaries(query: { + page: number; + pageSize: number; + primaryCategory?: ServiceCategory; + status?: string; + keyCareFlag?: boolean; +}): Promise { + return apiRequest(`/api/v1/admin/beneficiaries?${queryString(query)}`); +} + +export function fetchBeneficiary(publicId: string): Promise { + return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`); +} + +export function updateBeneficiary( + publicId: string, + input: UpdateBeneficiaryRequest, +): Promise { + return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`, { + method: 'PUT', + body: JSON.stringify(input), + }); +} + +export function fetchAssigneeOptions(): Promise { + return apiRequest('/api/v1/admin/service-cases/assignee-options'); +} + +export function fetchServiceCases(query: { + page: number; + pageSize: number; + status?: ServiceCaseStatus; + category?: ServiceCategory; + caseNo?: string; +}): Promise { + return apiRequest(`/api/v1/admin/service-cases?${queryString(query)}`); +} + +export function fetchServiceCase(publicId: string): Promise { + return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}`); +} + +export function startServiceCase(publicId: string): Promise { + return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/start`, { + method: 'POST', + body: '{}', + }); +} + +export function completeServiceCase( + publicId: string, + input: CompleteServiceCaseRequest, +): Promise { + return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/complete`, { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export function replaceCaseAssignees( + publicId: string, + input: ReplaceCaseAssigneesRequest, +): Promise { + return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/assignees`, { + method: 'PUT', + body: JSON.stringify(input), + }); +} + +export function createServiceRecord( + publicId: string, + input: CreateServiceRecordRequest, +): Promise { + return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/records`, { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export function updateServiceRecord( + casePublicId: string, + recordPublicId: string, + input: UpdateServiceRecordRequest, +): Promise { + return apiRequest( + `/api/v1/admin/service-cases/${encodeURIComponent(casePublicId)}/records/${encodeURIComponent(recordPublicId)}`, + { method: 'PATCH', body: JSON.stringify(input) }, + ); +} + +export function createUploadIntent( + casePublicId: string, + input: CreateUploadIntentRequest, +): Promise { + return apiRequest( + `/api/v1/service-cases/${encodeURIComponent(casePublicId)}/files/upload-intents`, + { method: 'POST', body: JSON.stringify(input) }, + ); +} + +export function confirmUpload(publicId: string): Promise { + return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/confirm`, { + method: 'POST', + body: '{}', + }); +} + +export function fetchDownloadUrl(publicId: string): Promise { + return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`); +} diff --git a/apps/admin-web/src/api/content-notifications.ts b/apps/admin-web/src/api/content-notifications.ts new file mode 100644 index 0000000..e55d920 --- /dev/null +++ b/apps/admin-web/src/api/content-notifications.ts @@ -0,0 +1,37 @@ +import type { + ContentPostDetailResponse, + CreateContentPostRequest, + PaginatedContentPostsResponse, + PaginatedNotificationDeliveriesResponse, + TransitionContentPostRequest, +} from '@lehuo/contracts'; +import { apiRequest } from './api-client'; +const query = (v: Record) => { + const p = new URLSearchParams(); + Object.entries(v).forEach(([k, x]) => x !== undefined && p.set(k, String(x))); + return p.toString(); +}; +export const fetchContent = (v: { + page: number; + pageSize: number; + status?: string; + type?: string; +}) => apiRequest(`/api/v1/admin/content?${query(v)}`); +export const createContent = (input: CreateContentPostRequest) => + apiRequest('/api/v1/admin/content', { + method: 'POST', + body: JSON.stringify(input), + }); +export const transitionContent = (id: string, input: TransitionContentPostRequest) => + apiRequest(`/api/v1/admin/content/${id}/transition`, { + method: 'POST', + body: JSON.stringify(input), + }); +export const fetchDeliveries = (v: { page: number; pageSize: number; status?: string }) => + apiRequest( + `/api/v1/admin/notification-deliveries?${query(v)}`, + ); +export const retryDelivery = (id: string) => + apiRequest<{ queued: true }>(`/api/v1/admin/notification-deliveries/${id}/retry`, { + method: 'POST', + }); diff --git a/apps/admin-web/src/api/follow-ups.ts b/apps/admin-web/src/api/follow-ups.ts new file mode 100644 index 0000000..af3821a --- /dev/null +++ b/apps/admin-web/src/api/follow-ups.ts @@ -0,0 +1,55 @@ +import type { + CompleteFollowUpRequest, + CreateFollowUpRequest, + FollowUpDetailResponse, + FollowUpMutationResponse, + FollowUpStatus, + FollowUpType, + FollowUpWorkbenchSummary, + PaginatedFollowUpsResponse, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +function queryString(values: Record): string { + const params = new URLSearchParams(); + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== '') params.set(key, String(value)); + }); + return params.toString(); +} + +export function fetchFollowUpSummary(): Promise { + return apiRequest('/api/v1/admin/follow-ups/summary'); +} + +export function fetchFollowUps(query: { + page: number; + pageSize: number; + status?: FollowUpStatus; + followUpType?: FollowUpType; + overdue?: boolean; +}): Promise { + return apiRequest(`/api/v1/admin/follow-ups?${queryString(query)}`); +} + +export function fetchFollowUp(publicId: string): Promise { + return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}`); +} + +export function createFollowUp(input: CreateFollowUpRequest): Promise { + return apiRequest('/api/v1/admin/follow-ups', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export function completeFollowUp( + publicId: string, + input: CompleteFollowUpRequest, +): Promise { + return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}/complete`, { + method: 'POST', + body: JSON.stringify(input), + }); +} diff --git a/apps/admin-web/src/api/participation.ts b/apps/admin-web/src/api/participation.ts new file mode 100644 index 0000000..f261b9b --- /dev/null +++ b/apps/admin-web/src/api/participation.ts @@ -0,0 +1,89 @@ +import type { + ActivityDetailResponse, + ActivityRegistrationResponse, + AssigneeOption, + CheckInActivityRegistrationRequest, + CreateActivityRequest, + CreateProjectRequest, + PaginatedActivitiesResponse, + PaginatedActivityRegistrationsResponse, + PaginatedProjectsResponse, + PaginatedVolunteersResponse, + ProjectDetailResponse, + RecordServiceMinutesRequest, + ReviewActivityRegistrationRequest, + ReviewVolunteerRequest, + TransitionActivityRequest, + TransitionProjectRequest, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +const query = (values: Record) => { + const params = new URLSearchParams(); + Object.entries(values).forEach( + ([key, value]) => value !== undefined && params.set(key, String(value)), + ); + return params.toString(); +}; + +export const fetchVolunteers = (values: { page: number; pageSize: number; status?: string }) => + apiRequest(`/api/v1/admin/volunteers?${query(values)}`); +export const reviewVolunteer = (publicId: string, input: ReviewVolunteerRequest) => + apiRequest(`/api/v1/admin/volunteers/${publicId}/review`, { + method: 'POST', + body: JSON.stringify(input), + }); + +export const fetchProjects = (values: { page: number; pageSize: number; status?: string }) => + apiRequest(`/api/v1/admin/projects?${query(values)}`); +export const fetchProjectManagerOptions = () => + apiRequest('/api/v1/admin/project-manager-options'); +export const createProject = (input: CreateProjectRequest) => + apiRequest('/api/v1/admin/projects', { + method: 'POST', + body: JSON.stringify(input), + }); +export const transitionProject = (publicId: string, input: TransitionProjectRequest) => + apiRequest(`/api/v1/admin/projects/${publicId}/transition`, { + method: 'POST', + body: JSON.stringify(input), + }); + +export const fetchActivities = (values: { page: number; pageSize: number; status?: string }) => + apiRequest(`/api/v1/admin/activities?${query(values)}`); +export const createActivity = (input: CreateActivityRequest) => + apiRequest('/api/v1/admin/activities', { + method: 'POST', + body: JSON.stringify(input), + }); +export const transitionActivity = (publicId: string, input: TransitionActivityRequest) => + apiRequest(`/api/v1/admin/activities/${publicId}/transition`, { + method: 'POST', + body: JSON.stringify(input), + }); + +export const fetchRegistrations = (values: { + page: number; + pageSize: number; + status?: string; + activityPublicId?: string; +}) => + apiRequest( + `/api/v1/admin/activity-registrations?${query(values)}`, + ); +export const reviewRegistration = (publicId: string, input: ReviewActivityRegistrationRequest) => + apiRequest( + `/api/v1/admin/activity-registrations/${publicId}/review`, + { method: 'POST', body: JSON.stringify(input) }, + ); +export const checkInRegistration = (publicId: string, input: CheckInActivityRegistrationRequest) => + apiRequest( + `/api/v1/admin/activity-registrations/${publicId}/check-in`, + { method: 'POST', body: JSON.stringify(input) }, + ); +export const recordServiceMinutes = (publicId: string, input: RecordServiceMinutesRequest) => + apiRequest( + `/api/v1/admin/activity-registrations/${publicId}/service-minutes`, + { method: 'POST', body: JSON.stringify(input) }, + ); diff --git a/apps/admin-web/src/api/service-applications.ts b/apps/admin-web/src/api/service-applications.ts new file mode 100644 index 0000000..2bfe874 --- /dev/null +++ b/apps/admin-web/src/api/service-applications.ts @@ -0,0 +1,25 @@ +import type { + AdminServiceApplicationQuery, + PaginatedServiceApplicationsResponse, + ServiceApplicationDetailResponse, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +export function fetchAdminServiceApplications( + query: AdminServiceApplicationQuery, +): Promise { + const params = new URLSearchParams(); + if (query.page) params.set('page', String(query.page)); + if (query.pageSize) params.set('pageSize', String(query.pageSize)); + if (query.applicationNo) params.set('applicationNo', query.applicationNo); + if (query.serviceCategory) params.set('serviceCategory', query.serviceCategory); + if (query.status) params.set('status', query.status); + return apiRequest(`/api/v1/admin/service-applications?${params.toString()}`); +} + +export function fetchAdminServiceApplication( + publicId: string, +): Promise { + return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}`); +} diff --git a/apps/admin-web/src/api/statistics.ts b/apps/admin-web/src/api/statistics.ts new file mode 100644 index 0000000..35ccc1d --- /dev/null +++ b/apps/admin-web/src/api/statistics.ts @@ -0,0 +1,8 @@ +import type { StatisticsOverview } from '@lehuo/contracts'; +import { apiRequest } from './api-client'; +export const fetchStatistics = (startDate?: string, endDate?: string) => { + const p = new URLSearchParams(); + if (startDate) p.set('startDate', startDate); + if (endDate) p.set('endDate', endDate); + return apiRequest(`/api/v1/admin/statistics/overview?${p.toString()}`); +}; diff --git a/apps/admin-web/src/app-info.spec.ts b/apps/admin-web/src/app-info.spec.ts new file mode 100644 index 0000000..0d80dcc --- /dev/null +++ b/apps/admin-web/src/app-info.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import { ADMIN_APP_TITLE } from './app-info'; + +describe('admin app information', () => { + it('uses the approved public-service brand', () => { + expect(ADMIN_APP_TITLE).toContain('乐活公益'); + }); +}); diff --git a/apps/admin-web/src/app-info.ts b/apps/admin-web/src/app-info.ts new file mode 100644 index 0000000..a73e7dd --- /dev/null +++ b/apps/admin-web/src/app-info.ts @@ -0,0 +1,2 @@ +export const ADMIN_APP_TITLE = '乐活公益管理后台'; +export const PHASE_ZERO_MESSAGE = '工程基线已就绪,业务功能将在后续阶段实现。'; diff --git a/apps/admin-web/src/application-format.spec.ts b/apps/admin-web/src/application-format.spec.ts new file mode 100644 index 0000000..a82e75e --- /dev/null +++ b/apps/admin-web/src/application-format.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { formatChinaDateTime, statusTone } from './application-format'; + +describe('application formatting', () => { + it('formats API timestamps for China display', () => { + expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toContain('2026-08-11'); + expect(formatChinaDateTime('invalid')).toBe('时间待确认'); + }); + + it('uses text-compatible semantic status tones', () => { + expect(statusTone('SUBMITTED')).toBe('info'); + expect(statusTone('CLOSED')).toBe('success'); + expect(statusTone('REJECTED')).toBe('danger'); + }); +}); diff --git a/apps/admin-web/src/application-format.ts b/apps/admin-web/src/application-format.ts new file mode 100644 index 0000000..bfdbaef --- /dev/null +++ b/apps/admin-web/src/application-format.ts @@ -0,0 +1,24 @@ +import type { ServiceApplicationStatus } from '@lehuo/contracts'; + +export function formatChinaDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '时间待确认'; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .format(date) + .replaceAll('/', '-'); +} + +export function statusTone(status: ServiceApplicationStatus): string { + if (['REJECTED', 'OUT_OF_SCOPE', 'CANCELLED'].includes(status)) return 'danger'; + if (['CLOSED', 'SERVICE_COMPLETED'].includes(status)) return 'success'; + if (['SUSPENDED', 'FOLLOW_UP_PENDING'].includes(status)) return 'warning'; + return 'info'; +} diff --git a/apps/admin-web/src/login-form.spec.ts b/apps/admin-web/src/login-form.spec.ts new file mode 100644 index 0000000..99e464e --- /dev/null +++ b/apps/admin-web/src/login-form.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { validateLoginForm } from './login-form'; + +describe('validateLoginForm', () => { + it('accepts a valid staff login form', () => { + expect(validateLoginForm('staff.admin', 'correct horse battery staple')).toEqual({}); + }); + + it('returns field-level recovery guidance', () => { + expect(validateLoginForm('?', 'short')).toEqual({ + username: '请输入有效的登录账号', + password: '密码长度应为 12 至 128 个字符', + }); + }); +}); diff --git a/apps/admin-web/src/login-form.ts b/apps/admin-web/src/login-form.ts new file mode 100644 index 0000000..f7a48c5 --- /dev/null +++ b/apps/admin-web/src/login-form.ts @@ -0,0 +1,15 @@ +export interface LoginFormErrors { + username?: string; + password?: string; +} + +export function validateLoginForm(username: string, password: string): LoginFormErrors { + const errors: LoginFormErrors = {}; + if (!/^[a-z0-9._-]{3,64}$/.test(username.trim().toLowerCase())) { + errors.username = '请输入有效的登录账号'; + } + if (password.length < 12 || password.length > 128) { + errors.password = '密码长度应为 12 至 128 个字符'; + } + return errors; +} diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts new file mode 100644 index 0000000..a6acc16 --- /dev/null +++ b/apps/admin-web/src/main.ts @@ -0,0 +1,7 @@ +import { createPinia } from 'pinia'; +import { createApp } from 'vue'; + +import App from './App.vue'; +import './styles.css'; + +createApp(App).use(createPinia()).mount('#app'); diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts new file mode 100644 index 0000000..36571ec --- /dev/null +++ b/apps/admin-web/src/stores/auth.ts @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; + +import type { AdminLoginResponse, AuthUser, CurrentUserResponse } from '@lehuo/contracts'; + +import { apiRequest } from '../api/api-client'; + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null); + const checking = ref(true); + const authenticated = computed(() => user.value !== null); + + async function restore(): Promise { + checking.value = true; + try { + const result = await apiRequest('/api/v1/admin/auth/me'); + user.value = result.user; + } catch { + user.value = null; + } finally { + checking.value = false; + } + } + + async function login(username: string, password: string): Promise { + const result = await apiRequest('/api/v1/admin/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); + user.value = result.user; + } + + async function logout(): Promise { + await apiRequest('/api/v1/admin/auth/logout', { method: 'POST', body: '{}' }); + user.value = null; + } + + return { user, checking, authenticated, restore, login, logout }; +}); diff --git a/apps/admin-web/src/styles.css b/apps/admin-web/src/styles.css new file mode 100644 index 0000000..e16ac5d --- /dev/null +++ b/apps/admin-web/src/styles.css @@ -0,0 +1,803 @@ +:root { + --color-primary: #ff6b35; + --color-primary-hover: #e95a28; + --color-primary-soft: #fff1eb; + --color-text: #1f2329; + --color-text-secondary: #646a73; + --color-danger: #b42318; + --color-success: #237a2b; + --color-warning: #8a5a00; + --color-info: #145ea8; + --color-surface: #fff; + --color-background: #f5f5f5; + --color-border: #d9dce1; + --color-sidebar: #263342; + --focus-ring: 0 0 0 3px rgb(255 107 53 / 28%); + color: var(--color-text); + background: var(--color-background); + font-family: + -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', + sans-serif; +} + +* { + box-sizing: border-box; +} +body { + min-width: 320px; + min-height: 100vh; + margin: 0; +} +button, +input, +select, +textarea { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +button:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.app-shell { + min-height: 100vh; + padding: 24px 16px; +} +.app-shell--centered { + display: grid; + place-items: center; +} +.welcome-card, +.login-card { + width: min(560px, 100%); + padding: 32px; + border: 1px solid var(--color-border); + border-radius: 16px; + background: var(--color-surface); + box-shadow: 0 4px 16px rgb(0 0 0 / 6%); +} +.welcome-card h1, +.login-card h1 { + margin: 8px 0 16px; + font-size: clamp(24px, 4vw, 32px); + line-height: 1.25; +} +.welcome-card p, +.login-card p { + line-height: 1.7; +} +.eyebrow { + margin: 0; + color: var(--color-primary-hover); + font-weight: 600; +} +.intro { + margin-bottom: 24px; + color: var(--color-text-secondary); +} +.login-card form { + display: grid; + gap: 8px; +} +.login-card label { + margin-top: 8px; + font-weight: 600; +} +.login-card input { + width: 100%; + min-height: 48px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 12px; + color: var(--color-text); + background: var(--color-surface); +} +.login-card input:hover { + border-color: #aeb3bb; +} +input[aria-invalid='true'] { + border-color: var(--color-danger); +} +.password-field { + position: relative; +} +.password-field input { + padding-right: 72px; +} +.password-toggle { + position: absolute; + top: 2px; + right: 2px; + min-width: 64px; + min-height: 44px; + border: 0; + border-radius: 10px; + color: var(--color-primary-hover); + background: transparent; +} +.field-error, +.form-message, +.global-message { + margin: 0; + color: var(--color-danger); + font-size: 14px; +} + +.primary-button, +.secondary-button { + min-height: 48px; + margin-top: 16px; + padding: 10px 20px; + border-radius: 12px; + font-weight: 600; + transition: + background-color 180ms ease, + border-color 180ms ease; +} +.primary-button { + border: 1px solid var(--color-primary); + color: #fff; + background: var(--color-primary); +} +.primary-button:hover:not(:disabled) { + border-color: var(--color-primary-hover); + background: var(--color-primary-hover); +} +.secondary-button { + border: 1px solid var(--color-primary); + color: var(--color-primary-hover); + background: var(--color-primary-soft); +} +.compact-button { + min-height: 44px; + margin: 0; + padding: 8px 18px; +} + +.admin-layout { + display: grid; + min-height: 100vh; + grid-template-columns: 240px minmax(0, 1fr); + background: var(--color-background); +} +.admin-sidebar { + position: sticky; + top: 0; + display: flex; + height: 100vh; + padding: 24px 16px; + flex-direction: column; + color: #fff; + background: var(--color-sidebar); +} +.brand-block { + display: flex; + min-height: 64px; + padding: 8px; + align-items: center; + gap: 12px; +} +.brand-block strong, +.brand-block small { + display: block; +} +.brand-block strong { + font-size: 18px; +} +.brand-block small { + margin-top: 4px; + color: #bfc8d2; +} +.brand-mark { + width: 36px; + height: 36px; + border: 8px solid var(--color-primary); + border-top-color: #ffb13b; + border-radius: 50% 50% 50% 16px; + transform: rotate(-18deg); +} +.admin-sidebar nav { + margin-top: 28px; + overflow-y: auto; +} +.nav-item { + width: 100%; + min-height: 48px; + padding: 10px 14px; + border: 0; + border-radius: 10px; + color: #e8edf2; + background: transparent; + text-align: left; +} +.nav-item--active { + color: #fff; + background: rgb(255 107 53 / 22%); + box-shadow: inset 3px 0 var(--color-primary); +} +.admin-sidebar nav { + display: grid; + gap: 6px; +} +.inline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.link-button--danger { + color: var(--color-danger); +} +.phase-note { + margin-top: auto; + padding: 14px; + border-radius: 10px; + color: #cbd3dc; + background: rgb(255 255 255 / 7%); + font-size: 13px; + line-height: 1.6; +} +.admin-main { + min-width: 0; +} +.admin-topbar { + display: flex; + min-height: 64px; + padding: 0 28px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--color-border); + background: #fff; +} +.admin-topbar strong, +.admin-topbar span { + display: block; +} +.admin-topbar span { + margin-top: 2px; + color: var(--color-text-secondary); + font-size: 13px; +} +.text-button, +.back-button, +.link-button { + min-height: 44px; + padding: 8px 12px; + border: 0; + border-radius: 8px; + color: var(--color-info); + background: transparent; + font-weight: 600; +} +.text-button:hover, +.back-button:hover, +.link-button:hover { + background: #eaf4ff; +} +.admin-main > .global-message { + margin: 12px 28px 0; +} + +.content-area { + width: min(1440px, 100%); + margin: 0 auto; + padding: 28px; +} +.page-header { + display: flex; + margin-bottom: 22px; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} +.page-header h1 { + margin: 0; + font-size: 28px; + line-height: 1.3; +} +.page-header p { + margin: 8px 0 0; + color: var(--color-text-secondary); + line-height: 1.6; +} +.back-button { + margin: -8px 0 8px -12px; +} +.result-count { + padding: 8px 12px; + border-radius: 8px; + color: var(--color-text-secondary); + background: #fff; + font-variant-numeric: tabular-nums; +} + +.filter-panel { + display: grid; + margin-bottom: 20px; + padding: 20px; + grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 1fr) minmax(150px, 1fr) auto; + align-items: end; + gap: 16px; + border: 1px solid var(--color-border); + border-radius: 14px; + background: #fff; +} +.filter-field { + display: grid; + gap: 8px; +} +.filter-field label { + font-size: 14px; + font-weight: 600; +} +.filter-field input, +.filter-field select { + width: 100%; + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 9px; + color: var(--color-text); + background: #fff; +} +.filter-actions { + display: flex; + gap: 10px; +} + +.panel, +.table-panel, +.empty-panel { + border: 1px solid var(--color-border); + border-radius: 14px; + background: #fff; + box-shadow: 0 3px 12px rgb(0 0 0 / 4%); +} +.panel { + padding: 24px; +} +.panel h2 { + margin: 0 0 20px; + font-size: 19px; +} +.panel h3 { + margin: 24px 0 10px; + font-size: 16px; +} +.empty-panel { + display: grid; + min-height: 280px; + padding: 32px; + place-content: center; + justify-items: center; + text-align: center; +} +.empty-panel h2 { + margin: 0; + font-size: 21px; +} +.empty-panel p { + max-width: 520px; + margin: 12px 0; + color: var(--color-text-secondary); + line-height: 1.7; +} +.table-scroll { + overflow-x: auto; +} +table { + width: 100%; + min-width: 980px; + border-collapse: collapse; +} +th, +td { + padding: 15px 16px; + border-bottom: 1px solid #ebecef; + text-align: left; + vertical-align: middle; +} +th { + color: #4e545d; + background: #fafafa; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} +td { + font-size: 14px; +} +tbody tr:hover { + background: #fffaf7; +} +.tabular { + font-variant-numeric: tabular-nums; +} +.pagination { + display: flex; + min-height: 64px; + padding: 10px 16px; + align-items: center; + justify-content: flex-end; + gap: 16px; +} +.pagination button { + min-height: 40px; + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); + background: #fff; +} +.pagination span { + color: var(--color-text-secondary); + font-size: 14px; +} + +.status-tag { + display: inline-flex; + min-height: 28px; + padding: 4px 9px; + align-items: center; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} +.status-tag--info { + color: var(--color-info); + background: #eaf4ff; +} +.status-tag--success { + color: var(--color-success); + background: #edf8ee; +} +.status-tag--warning { + color: var(--color-warning); + background: #fff6d8; +} +.status-tag--danger { + color: var(--color-danger); + background: #fff0ee; +} + +.audit-notice { + margin-bottom: 20px; + padding: 14px 16px; + border-left: 4px solid #d18a00; + border-radius: 8px; + color: #604500; + background: #fff7de; + line-height: 1.6; +} +.detail-grid { + display: grid; + margin-bottom: 20px; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.detail-list { + margin: 0; +} +.detail-list > div { + display: grid; + min-height: 48px; + padding: 11px 0; + grid-template-columns: 120px 1fr; + border-top: 1px solid #ebecef; +} +.detail-list dt { + color: var(--color-text-secondary); +} +.detail-list dd { + margin: 0; + overflow-wrap: anywhere; +} +.sensitive-value { + font-weight: 600; +} +.description-text { + margin: 0; + padding: 16px; + border-radius: 9px; + background: #fafafa; + line-height: 1.75; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.timeline-panel { + margin-bottom: 28px; +} +.workflow-panel { + margin-bottom: 20px; +} +.workflow-panel > p, +.consent-note { + color: var(--color-text-secondary); + line-height: 1.6; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 20px; +} +.form-grid label { + display: grid; + align-content: start; + gap: 8px; + font-size: 14px; + font-weight: 600; +} +.form-grid input:not([type='checkbox']), +.form-grid select, +.form-grid textarea { + width: 100%; + min-height: 44px; + padding: 9px 12px; + border: 1px solid var(--color-border); + border-radius: 9px; + color: var(--color-text); + background: #fff; +} +.form-grid select[multiple] { + min-height: 132px; +} +.form-grid textarea { + min-height: 112px; + resize: vertical; + line-height: 1.6; +} +.form-span { + grid-column: 1 / -1; +} +.form-actions { + display: flex; + gap: 12px; +} +.checkbox-group { + display: flex; + padding: 14px; + flex-wrap: wrap; + gap: 12px 20px; + border: 1px solid var(--color-border); + border-radius: 10px; +} +.checkbox-group legend { + padding: 0 6px; + font-weight: 600; +} +.contact-summary { + padding: 16px; + border-radius: 10px; + background: #fafafa; +} +.contact-summary p { + margin: 8px 0 0; +} +.record-list { + margin-top: 20px; +} +.summary-grid { + display: grid; + margin-bottom: 20px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} +.summary-card { + display: grid; + min-height: 126px; + padding: 18px; + align-content: space-between; + border: 1px solid var(--color-border); + border-radius: 14px; + color: var(--color-text); + background: #fff; + text-align: left; + box-shadow: 0 3px 12px rgb(0 0 0 / 4%); +} +button.summary-card:hover { + border-color: var(--color-danger); + background: #fff8f7; +} +.summary-card strong { + font-size: 32px; + font-variant-numeric: tabular-nums; +} +.summary-card small, +.block-note { + color: var(--color-text-secondary); +} +.summary-card--danger strong, +.danger-text { + color: var(--color-danger); +} +.block-note { + display: block; + margin-top: 4px; + font-size: 12px; +} +.consent-check { + display: flex !important; + min-height: 44px; + align-items: center; + gap: 10px; + line-height: 1.5; +} +.record-card { + padding: 18px 0; + border-top: 1px solid #ebecef; +} +.record-card header { + display: flex; + justify-content: space-between; + gap: 16px; +} +.record-card time { + color: var(--color-text-secondary); + font-size: 13px; +} +.record-card p { + line-height: 1.65; + white-space: pre-wrap; +} +.admin-timeline { + margin: 0; + padding: 0; + list-style: none; +} +.admin-timeline li { + position: relative; + display: grid; + min-height: 104px; + grid-template-columns: 24px 1fr; + gap: 14px; +} +.admin-timeline li:not(:last-child)::before { + position: absolute; + top: 18px; + bottom: -4px; + left: 7px; + width: 2px; + content: ''; + background: #ffd3c1; +} +.timeline-marker { + position: relative; + z-index: 1; + width: 16px; + height: 16px; + margin-top: 3px; + border: 4px solid #ffe4d8; + border-radius: 50%; + background: var(--color-primary); +} +.admin-timeline strong { + font-size: 16px; +} +.admin-timeline p { + margin: 6px 0; + color: var(--color-text-secondary); +} +.admin-timeline time { + color: #767d87; + font-size: 13px; + font-variant-numeric: tabular-nums; +} +.table-skeleton { + height: 50px; + margin-bottom: 10px; + border-radius: 8px; + background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%); + background-size: 200% 100%; + animation: skeleton 1.4s ease-in-out infinite; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes skeleton { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (max-width: 1100px) { + .admin-layout { + grid-template-columns: 190px minmax(0, 1fr); + } + .filter-panel { + grid-template-columns: 1fr 1fr; + } + .filter-actions { + align-self: end; + } + .detail-grid { + grid-template-columns: 1fr; + } + .form-grid { + grid-template-columns: 1fr; + } + .form-span { + grid-column: auto; + } + .summary-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 760px) { + .admin-layout { + display: block; + } + .admin-sidebar { + position: static; + width: 100%; + height: auto; + padding: 12px 16px; + } + .brand-block { + min-height: 52px; + } + .admin-sidebar nav, + .phase-note { + display: none; + } + .admin-topbar { + padding: 0 16px; + } + .content-area { + padding: 20px 16px; + } + .page-header { + flex-direction: column; + } + .filter-panel { + grid-template-columns: 1fr; + } + .filter-actions { + width: 100%; + } + .filter-actions button { + flex: 1; + } + .detail-list > div { + grid-template-columns: 100px 1fr; + } + .welcome-card, + .login-card { + padding: 24px 20px; + } + .summary-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/admin-web/src/views/ActivitiesView.vue b/apps/admin-web/src/views/ActivitiesView.vue new file mode 100644 index 0000000..51fed17 --- /dev/null +++ b/apps/admin-web/src/views/ActivitiesView.vue @@ -0,0 +1,370 @@ + + diff --git a/apps/admin-web/src/views/BeneficiariesView.vue b/apps/admin-web/src/views/BeneficiariesView.vue new file mode 100644 index 0000000..fd67684 --- /dev/null +++ b/apps/admin-web/src/views/BeneficiariesView.vue @@ -0,0 +1,267 @@ + + + diff --git a/apps/admin-web/src/views/ContentView.vue b/apps/admin-web/src/views/ContentView.vue new file mode 100644 index 0000000..0254fca --- /dev/null +++ b/apps/admin-web/src/views/ContentView.vue @@ -0,0 +1,242 @@ + + diff --git a/apps/admin-web/src/views/FollowUpsView.vue b/apps/admin-web/src/views/FollowUpsView.vue new file mode 100644 index 0000000..bfd0f68 --- /dev/null +++ b/apps/admin-web/src/views/FollowUpsView.vue @@ -0,0 +1,469 @@ + + + diff --git a/apps/admin-web/src/views/NotificationDeliveriesView.vue b/apps/admin-web/src/views/NotificationDeliveriesView.vue new file mode 100644 index 0000000..91f47a0 --- /dev/null +++ b/apps/admin-web/src/views/NotificationDeliveriesView.vue @@ -0,0 +1,74 @@ + + diff --git a/apps/admin-web/src/views/ProjectsView.vue b/apps/admin-web/src/views/ProjectsView.vue new file mode 100644 index 0000000..8ad48ab --- /dev/null +++ b/apps/admin-web/src/views/ProjectsView.vue @@ -0,0 +1,235 @@ + + diff --git a/apps/admin-web/src/views/ServiceApplicationsView.vue b/apps/admin-web/src/views/ServiceApplicationsView.vue new file mode 100644 index 0000000..f9e31b7 --- /dev/null +++ b/apps/admin-web/src/views/ServiceApplicationsView.vue @@ -0,0 +1,572 @@ + + + diff --git a/apps/admin-web/src/views/ServiceCasesView.vue b/apps/admin-web/src/views/ServiceCasesView.vue new file mode 100644 index 0000000..d044b35 --- /dev/null +++ b/apps/admin-web/src/views/ServiceCasesView.vue @@ -0,0 +1,590 @@ + + + diff --git a/apps/admin-web/src/views/StatisticsView.vue b/apps/admin-web/src/views/StatisticsView.vue new file mode 100644 index 0000000..077d110 --- /dev/null +++ b/apps/admin-web/src/views/StatisticsView.vue @@ -0,0 +1,77 @@ + + diff --git a/apps/admin-web/src/views/VolunteersView.vue b/apps/admin-web/src/views/VolunteersView.vue new file mode 100644 index 0000000..5f5c202 --- /dev/null +++ b/apps/admin-web/src/views/VolunteersView.vue @@ -0,0 +1,170 @@ + + + diff --git a/apps/admin-web/src/vue-shim.d.ts b/apps/admin-web/src/vue-shim.d.ts new file mode 100644 index 0000000..8afcdfb --- /dev/null +++ b/apps/admin-web/src/vue-shim.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent; + export default component; +} diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json new file mode 100644 index 0000000..ce2e2a4 --- /dev/null +++ b/apps/admin-web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts new file mode 100644 index 0000000..7b6cf7e --- /dev/null +++ b/apps/admin-web/vite.config.ts @@ -0,0 +1,6 @@ +import vue from '@vitejs/plugin-vue'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..9da4a3e --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,25 @@ +FROM node:24.13.0-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile +COPY apps/api apps/api +COPY packages/contracts packages/contracts +RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/api... build + +FROM node:24.13.0-alpine AS runtime +ENV NODE_ENV=production +WORKDIR /app +RUN corepack enable +COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/api/package.json ./apps/api/package.json +COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=build /app/apps/api/dist ./apps/api/dist +COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json +COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist +USER node +EXPOSE 3000 +CMD ["node", "apps/api/dist/main.js"] diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..b19620e --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,7 @@ +# API + +NestJS 模块化单体 API。已完成 Phase 0 至 Phase 5:健康检查、认证/RBAC/审计、服务申请、服务对象与工单、服务记录与私有附件、回访闭环、公益项目、活动报名、志愿者审核、签到和服务时长。关键状态变更事务一致,敏感档案/记录加密,修改保留 revision 或不可变事实历史。 + +开发启动:`pnpm --filter @lehuo/api dev`。 + +附件和项目/活动封面使用 S3 兼容私有对象存储 Adapter。必须配置 `.env.example` 中的 `OBJECT_STORAGE_*` 才能签发上传/下载 URL;未配置时 API 明确返回服务不可用,不使用本地磁盘或模拟存储冒充生产能力。内容与通知属于 Phase 6,尚未开放。 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..bdd5e41 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lehuo/api", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "bootstrap:admin": "tsx src/scripts/bootstrap-admin.ts", + "build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json", + "db:deploy": "prisma migrate deploy", + "db:generate": "prisma generate", + "dev": "pnpm --filter @lehuo/contracts build && tsx watch src/main.ts", + "start": "node dist/main.js", + "test:integration": "pnpm --filter @lehuo/contracts build && tsx test/run-integration.ts", + "test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts", + "typecheck": "prisma generate && tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1106.0", + "@aws-sdk/s3-request-presigner": "^3.1106.0", + "@lehuo/contracts": "workspace:*", + "@nestjs/common": "^11.1.6", + "@nestjs/core": "^11.1.6", + "@nestjs/platform-express": "^11.1.6", + "@nestjs/swagger": "^11.4.6", + "@nestjs/throttler": "^6.5.0", + "@prisma/adapter-mariadb": "^7.9.1", + "@prisma/client": "^7.9.1", + "argon2": "^0.45.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", + "mariadb": "^3.5.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@nestjs/testing": "^11.1.6", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.6", + "@types/node": "^24.3.0", + "@types/supertest": "^6.0.3", + "dotenv": "^17.4.2", + "prisma": "^7.9.1", + "supertest": "^7.1.4", + "tsx": "^4.20.5", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts new file mode 100644 index 0000000..a0d2fbc --- /dev/null +++ b/apps/api/prisma.config.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; + +import { defineConfig, env } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql b/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql new file mode 100644 index 0000000..a3e7490 --- /dev/null +++ b/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql @@ -0,0 +1,184 @@ +-- Phase 1 authentication and authorization baseline. +-- Internal BIGINT identifiers are never exposed through public APIs. + +CREATE TABLE `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `status` ENUM('ACTIVE', 'SUSPENDED', 'DISABLED') NOT NULL DEFAULT 'ACTIVE', + `user_type` ENUM('PUBLIC', 'STAFF') NOT NULL, + `mobile_encrypted` VARCHAR(512) NULL, + `mobile_hash` CHAR(64) NULL, + `nickname` VARCHAR(100) NULL, + `avatar_url` VARCHAR(512) NULL, + `last_login_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `users_public_id_key`(`public_id`), + UNIQUE INDEX `users_mobile_hash_key`(`mobile_hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `wechat_identities` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `appid` VARCHAR(64) NOT NULL, + `openid_ciphertext` VARCHAR(512) NOT NULL, + `openid_hash` CHAR(64) NOT NULL, + `unionid_ciphertext` VARCHAR(512) NULL, + `unionid_hash` CHAR(64) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + INDEX `wechat_identities_user_id_idx`(`user_id`), + UNIQUE INDEX `wechat_identities_appid_openid_hash_key`(`appid`, `openid_hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `staff_profiles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `employee_no` VARCHAR(50) NULL, + `display_name` VARCHAR(100) NOT NULL, + `department` VARCHAR(100) NULL, + `status` ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `staff_profiles_user_id_key`(`user_id`), + UNIQUE INDEX `staff_profiles_employee_no_key`(`employee_no`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `admin_credentials` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `username` VARCHAR(64) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `failed_login_count` INTEGER UNSIGNED NOT NULL DEFAULT 0, + `locked_until` DATETIME(3) NULL, + `password_changed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `admin_credentials_user_id_key`(`user_id`), + UNIQUE INDEX `admin_credentials_username_key`(`username`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `auth_sessions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `channel` ENUM('WECHAT', 'ADMIN') NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `last_used_at` DATETIME(3) NULL, + `revoked_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `auth_sessions_public_id_key`(`public_id`), + UNIQUE INDEX `auth_sessions_token_hash_key`(`token_hash`), + INDEX `auth_sessions_user_channel_status_idx`(`user_id`, `channel`, `revoked_at`, `expires_at`), + INDEX `auth_sessions_expires_at_idx`(`expires_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `roles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `roles_public_id_key`(`public_id`), + UNIQUE INDEX `roles_code_key`(`code`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `permissions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(100) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `permissions_code_key`(`code`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `user_roles` ( + `user_id` BIGINT UNSIGNED NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL, + `assigned_by` BIGINT UNSIGNED NULL, + `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `user_roles_role_id_idx`(`role_id`), + INDEX `user_roles_assigned_by_idx`(`assigned_by`), + PRIMARY KEY (`user_id`, `role_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `role_permissions` ( + `role_id` BIGINT UNSIGNED NOT NULL, + `permission_id` BIGINT UNSIGNED NOT NULL, + INDEX `role_permissions_permission_id_idx`(`permission_id`), + PRIMARY KEY (`role_id`, `permission_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `audit_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` CHAR(36) NOT NULL, + `actor_user_id` BIGINT UNSIGNED NULL, + `actor_role` VARCHAR(64) NULL, + `action` VARCHAR(100) NOT NULL, + `resource_type` VARCHAR(100) NULL, + `resource_public_id` VARCHAR(100) NULL, + `result` ENUM('SUCCESS', 'FAILURE') NOT NULL, + `metadata_json` JSON NULL, + `ip_hash` CHAR(64) NULL, + `user_agent_summary` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `audit_logs_actor_created_at_idx`(`actor_user_id`, `created_at`), + INDEX `audit_logs_resource_created_at_idx`(`resource_type`, `resource_public_id`, `created_at`), + INDEX `audit_logs_action_created_at_idx`(`action`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `wechat_identities` ADD CONSTRAINT `wechat_identities_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `staff_profiles` ADD CONSTRAINT `staff_profiles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `admin_credentials` ADD CONSTRAINT `admin_credentials_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `auth_sessions` ADD CONSTRAINT `auth_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permission_id_fkey` FOREIGN KEY (`permission_id`) REFERENCES `permissions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_actor_user_id_fkey` FOREIGN KEY (`actor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO `roles` (`public_id`, `code`, `name`, `description`, `updated_at`) VALUES +('9fc73dca-5e75-4ce6-b8ad-d270bd020001', 'VOLUNTEER', '志愿者', '仅访问本人被分配的最小必要任务信息', CURRENT_TIMESTAMP(3)), +('9fc73dca-5e75-4ce6-b8ad-d270bd020002', 'STAFF', '工作人员', '按负责范围处理公益服务', CURRENT_TIMESTAMP(3)), +('9fc73dca-5e75-4ce6-b8ad-d270bd020003', 'PROJECT_MANAGER', '项目负责人', '按项目范围管理服务', CURRENT_TIMESTAMP(3)), +('9fc73dca-5e75-4ce6-b8ad-d270bd020004', 'ADMIN', '管理员', '跨项目业务管理', CURRENT_TIMESTAMP(3)), +('9fc73dca-5e75-4ce6-b8ad-d270bd020005', 'SUPER_ADMIN', '超级管理员', '系统级配置维护,日常不使用', CURRENT_TIMESTAMP(3)); + +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('admin:access', '访问管理后台', '允许进入管理后台'), +('users:read', '查看用户', '按授权范围查看用户基础信息'), +('users:manage', '管理用户', '管理用户状态与基础信息'), +('roles:manage', '管理角色权限', '分配角色和维护权限'), +('audit:read', '查看审计日志', '按权限查看审计记录'), +('system:manage', '管理系统配置', '维护系统级配置'), +('sensitive:read', '查看敏感信息', '经审计查看必要敏感信息'), +('sensitive:export', '导出敏感信息', '受控导出敏感信息'), +('resources:access_all', '跨范围访问资源', '按角色范围访问非本人资源'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'STAFF' AND p.code IN ('admin:access', 'users:read'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'PROJECT_MANAGER' AND p.code IN ('admin:access', 'users:read', 'resources:access_all'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'ADMIN' AND p.code IN ('admin:access', 'users:read', 'users:manage', 'roles:manage', 'audit:read', 'sensitive:read', 'resources:access_all'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'SUPER_ADMIN'; diff --git a/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql b/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql new file mode 100644 index 0000000..d5b7c18 --- /dev/null +++ b/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql @@ -0,0 +1,81 @@ +-- Phase 2 service application baseline. +-- L3 fields are encrypted by the application before persistence. + +CREATE TABLE `consents` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `consent_type` ENUM('SERVICE_APPLICATION') NOT NULL, + `version` VARCHAR(32) NOT NULL, + `granted_by` ENUM('SELF', 'APPLICANT') NOT NULL, + `granted_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + `evidence_json` JSON NULL, + `ip_hash` CHAR(64) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `consents_public_id_key`(`public_id`), + INDEX `consents_user_type_granted_at_idx`(`user_id`, `consent_type`, `granted_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `service_applications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `application_no` VARCHAR(32) NOT NULL, + `applicant_user_id` BIGINT UNSIGNED NOT NULL, + `consent_id` BIGINT UNSIGNED NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `apply_mode` ENUM('SELF', 'FAMILY', 'COMMUNITY', 'VOLUNTEER', 'OTHER') NOT NULL, + `service_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL, + `beneficiary_name_encrypted` VARCHAR(512) NOT NULL, + `contact_mobile_encrypted` VARCHAR(512) NOT NULL, + `contact_mobile_hash` CHAR(64) NOT NULL, + `region_code` VARCHAR(100) NOT NULL, + `description_encrypted` TEXT NOT NULL, + `preferred_contact_time` VARCHAR(100) NULL, + `needs_home_visit` BOOLEAN NULL, + `mobility_difficulty` BOOLEAN NULL, + `status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL DEFAULT 'SUBMITTED', + `source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM', + `submitted_at` DATETIME(3) NOT NULL, + `accepted_at` DATETIME(3) NULL, + `closed_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `service_applications_public_id_key`(`public_id`), + UNIQUE INDEX `service_applications_application_no_key`(`application_no`), + UNIQUE INDEX `service_applications_consent_id_key`(`consent_id`), + UNIQUE INDEX `service_applications_applicant_client_request_key`(`applicant_user_id`, `client_request_id`), + INDEX `service_applications_applicant_submitted_at_idx`(`applicant_user_id`, `submitted_at`), + INDEX `service_applications_status_submitted_at_idx`(`status`, `submitted_at`), + INDEX `service_applications_category_submitted_at_idx`(`service_category`, `submitted_at`), + INDEX `service_applications_contact_mobile_hash_idx`(`contact_mobile_hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `service_application_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `application_id` BIGINT UNSIGNED NOT NULL, + `from_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NULL, + `to_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `application_status_history_application_created_at_idx`(`application_id`, `created_at`), + INDEX `application_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `consents` ADD CONSTRAINT `consents_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_applicant_user_id_fkey` FOREIGN KEY (`applicant_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('applications:read', '查看服务申请', '按授权范围查看服务申请列表和详情'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') AND p.code = 'applications:read'; diff --git a/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql b/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql new file mode 100644 index 0000000..f1e9f4a --- /dev/null +++ b/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql @@ -0,0 +1,359 @@ +-- AlterTable +ALTER TABLE `consents` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL, + MODIFY `user_id` BIGINT UNSIGNED NULL, + MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN') NOT NULL, + MODIFY `granted_by` ENUM('SELF', 'APPLICANT', 'GUARDIAN') NOT NULL; + +-- AlterTable +ALTER TABLE `service_applications` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL; + +-- CreateTable +CREATE TABLE `beneficiary_profiles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `display_name_encrypted` VARCHAR(512) NOT NULL, + `display_name_hash` CHAR(64) NOT NULL, + `gender` ENUM('FEMALE', 'MALE', 'OTHER', 'UNDISCLOSED') NULL, + `birth_year` SMALLINT UNSIGNED NULL, + `mobile_encrypted` VARCHAR(512) NULL, + `mobile_hash` CHAR(64) NULL, + `region_code` VARCHAR(100) NOT NULL, + `address_detail_encrypted` VARCHAR(1024) NULL, + `primary_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL, + `key_care_flag` BOOLEAN NOT NULL DEFAULT false, + `is_minor` BOOLEAN NOT NULL DEFAULT false, + `status` ENUM('ACTIVE', 'INACTIVE', 'ARCHIVED') NOT NULL DEFAULT 'ACTIVE', + `notes_encrypted` TEXT NULL, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `beneficiary_profiles_public_id_key`(`public_id`), + INDEX `beneficiary_profiles_display_name_hash_idx`(`display_name_hash`), + INDEX `beneficiary_profiles_mobile_hash_idx`(`mobile_hash`), + INDEX `beneficiary_profiles_category_status_created_at_idx`(`primary_category`, `status`, `created_at`), + INDEX `beneficiary_profiles_key_care_status_created_at_idx`(`key_care_flag`, `status`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `beneficiary_contacts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `beneficiary_id` BIGINT UNSIGNED NOT NULL, + `name_encrypted` VARCHAR(512) NOT NULL, + `relation` VARCHAR(50) NOT NULL, + `mobile_encrypted` VARCHAR(512) NOT NULL, + `mobile_hash` CHAR(64) NOT NULL, + `is_guardian` BOOLEAN NOT NULL DEFAULT false, + `is_emergency_contact` BOOLEAN NOT NULL DEFAULT false, + `consent_record_id` BIGINT UNSIGNED NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `removed_at` DATETIME(3) NULL, + + UNIQUE INDEX `beneficiary_contacts_public_id_key`(`public_id`), + UNIQUE INDEX `beneficiary_contacts_consent_record_id_key`(`consent_record_id`), + INDEX `beneficiary_contacts_beneficiary_active_guardian_idx`(`beneficiary_id`, `removed_at`, `is_guardian`), + INDEX `beneficiary_contacts_mobile_hash_idx`(`mobile_hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `tags` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(50) NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `tags_public_id_key`(`public_id`), + UNIQUE INDEX `tags_code_key`(`code`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `beneficiary_tags` ( + `beneficiary_id` BIGINT UNSIGNED NOT NULL, + `tag_id` BIGINT UNSIGNED NOT NULL, + `assigned_by` BIGINT UNSIGNED NOT NULL, + `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `beneficiary_tags_tag_id_idx`(`tag_id`), + PRIMARY KEY (`beneficiary_id`, `tag_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `service_cases` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `case_no` VARCHAR(32) NOT NULL, + `application_id` BIGINT UNSIGNED NOT NULL, + `accept_request_id` CHAR(36) NOT NULL, + `accept_request_fingerprint` CHAR(64) NOT NULL, + `beneficiary_id` BIGINT UNSIGNED NOT NULL, + `owner_staff_id` BIGINT UNSIGNED NOT NULL, + `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL, + `priority` ENUM('LOW', 'NORMAL', 'HIGH', 'URGENT') NOT NULL DEFAULT 'NORMAL', + `status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL DEFAULT 'ASSIGNED', + `planned_start_at` DATETIME(3) NULL, + `planned_end_at` DATETIME(3) NULL, + `service_location_encrypted` VARCHAR(1024) NULL, + `summary_encrypted` TEXT NOT NULL, + `result_summary_encrypted` TEXT NULL, + `follow_up_plan_encrypted` TEXT NULL, + `completed_at` DATETIME(3) NULL, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `service_cases_public_id_key`(`public_id`), + UNIQUE INDEX `service_cases_case_no_key`(`case_no`), + UNIQUE INDEX `service_cases_application_id_key`(`application_id`), + INDEX `service_cases_owner_status_planned_idx`(`owner_staff_id`, `status`, `planned_start_at`), + INDEX `service_cases_beneficiary_created_at_idx`(`beneficiary_id`, `created_at`), + INDEX `service_cases_status_created_at_idx`(`status`, `created_at`), + UNIQUE INDEX `service_cases_creator_accept_request_key`(`created_by`, `accept_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `case_assignees` ( + `case_id` BIGINT UNSIGNED NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `assignee_type` ENUM('STAFF', 'VOLUNTEER') NOT NULL, + `role` ENUM('OWNER', 'COLLABORATOR') NOT NULL, + `assigned_by` BIGINT UNSIGNED NOT NULL, + `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `removed_at` DATETIME(3) NULL, + + INDEX `case_assignees_user_active_assigned_idx`(`user_id`, `removed_at`, `assigned_at`), + PRIMARY KEY (`case_id`, `user_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `service_case_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `case_id` BIGINT UNSIGNED NOT NULL, + `from_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NULL, + `to_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `case_status_history_case_created_at_idx`(`case_id`, `created_at`), + INDEX `case_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `service_records` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `case_id` BIGINT UNSIGNED NOT NULL, + `beneficiary_id` BIGINT UNSIGNED NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `service_start_at` DATETIME(3) NOT NULL, + `service_end_at` DATETIME(3) NULL, + `service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL, + `content_encrypted` TEXT NOT NULL, + `result_encrypted` TEXT NOT NULL, + `follow_up_suggestion_encrypted` TEXT NULL, + `beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL DEFAULT 'NOT_REQUESTED', + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `service_records_public_id_key`(`public_id`), + INDEX `service_records_case_start_at_idx`(`case_id`, `service_start_at`), + INDEX `service_records_beneficiary_start_at_idx`(`beneficiary_id`, `service_start_at`), + UNIQUE INDEX `service_records_creator_client_request_key`(`created_by`, `client_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `service_record_revisions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `service_record_id` BIGINT UNSIGNED NOT NULL, + `revision` INTEGER UNSIGNED NOT NULL, + `service_start_at` DATETIME(3) NOT NULL, + `service_end_at` DATETIME(3) NULL, + `service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL, + `content_encrypted` TEXT NOT NULL, + `result_encrypted` TEXT NOT NULL, + `follow_up_suggestion_encrypted` TEXT NULL, + `beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL, + `revised_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `service_record_revisions_reviser_created_at_idx`(`revised_by`, `created_at`), + UNIQUE INDEX `service_record_revisions_record_revision_key`(`service_record_id`, `revision`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `files` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `case_id` BIGINT UNSIGNED NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `storage_provider` VARCHAR(50) NOT NULL, + `bucket` VARCHAR(255) NOT NULL, + `object_key` VARCHAR(512) NOT NULL, + `original_filename_encrypted` VARCHAR(1024) NOT NULL, + `mime_type` VARCHAR(100) NOT NULL, + `size` BIGINT UNSIGNED NOT NULL, + `sha256` CHAR(64) NULL, + `business_type` ENUM('SERVICE_RECORD') NOT NULL, + `uploader_user_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('UPLOAD_PENDING', 'AVAILABLE', 'QUARANTINED', 'DELETED') NOT NULL DEFAULT 'UPLOAD_PENDING', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `files_public_id_key`(`public_id`), + UNIQUE INDEX `files_object_key_key`(`object_key`), + INDEX `files_case_status_created_at_idx`(`case_id`, `status`, `created_at`), + INDEX `files_uploader_status_created_at_idx`(`uploader_user_id`, `status`, `created_at`), + UNIQUE INDEX `files_uploader_client_request_key`(`uploader_user_id`, `client_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `service_record_files` ( + `service_record_id` BIGINT UNSIGNED NOT NULL, + `file_id` BIGINT UNSIGNED NOT NULL, + `attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `service_record_files_file_id_idx`(`file_id`), + PRIMARY KEY (`service_record_id`, `file_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `consents_beneficiary_type_granted_at_idx` ON `consents`(`beneficiary_id`, `consent_type`, `granted_at`); + +-- CreateIndex +CREATE INDEX `service_applications_beneficiary_submitted_at_idx` ON `service_applications`(`beneficiary_id`, `submitted_at`); + +-- AddForeignKey +ALTER TABLE `consents` ADD CONSTRAINT `consents_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_consent_record_id_fkey` FOREIGN KEY (`consent_record_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_tag_id_fkey` FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_records` ADD CONSTRAINT `service_records_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_records` ADD CONSTRAINT `service_records_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_records` ADD CONSTRAINT `service_records_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_records` ADD CONSTRAINT `service_records_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_revised_by_fkey` FOREIGN KEY (`revised_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `files` ADD CONSTRAINT `files_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `files` ADD CONSTRAINT `files_uploader_user_id_fkey` FOREIGN KEY (`uploader_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_file_id_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Phase 3 fixed service tags. Product operations may request additions in a later reviewed migration. +INSERT INTO `tags` (`public_id`, `code`, `name`, `description`) VALUES +('4ed0570a-6051-4a29-9fd2-3c3d45dc52a7', 'ALONE_LIVING', '独居', '独居服务对象'), +('044792f8-2982-4890-a8d4-cc6d01ac78c4', 'ADVANCED_AGE', '高龄', '高龄关爱对象'), +('dc02d25f-5a4c-4f31-9ec8-c5a5f3904e6a', 'MOBILITY_LIMITED', '行动不便', '日常行动存在困难'), +('75292f5f-72e0-4a19-9e9b-5fd7b455956a', 'REGULAR_VISIT', '定期探访', '需要定期探访关爱'), +('94e041fe-0745-4f50-a3c6-b0cad8e286b5', 'CHILD_IN_DIFFICULTY', '困境儿童', '需要加强保护的儿童服务对象'), +('76e280f4-8f6f-4e06-9570-ce998c8c38c4', 'KEY_CARE', '重点关爱', '由工作人员评估的重点关爱对象'); + +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('applications:manage', '处理服务申请', '按状态机联系、评估并受理服务申请'), +('beneficiaries:read', '查看服务对象', '按授权范围查看服务对象档案'), +('beneficiaries:manage', '管理服务对象', '建档并维护服务对象、联系人和标签'), +('cases:read', '查看服务工单', '按负责或管理范围查看服务工单'), +('cases:manage', '管理服务工单', '按状态机推进负责的服务工单'), +('cases:assign', '分配服务工单', '分配负责人和协作人员'), +('assigned_cases:read', '查看已分配工单', '查看本人被分配工单的最小必要信息'), +('service_records:write', '填写服务记录', '为本人被分配工单新增或修订服务记录'), +('files:write', '管理服务附件', '上传、确认和读取授权范围内的私有服务附件'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') + AND p.code IN ('applications:manage', 'beneficiaries:read', 'beneficiaries:manage', 'cases:read', 'cases:manage', 'cases:assign', 'assigned_cases:read', 'service_records:write', 'files:write'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'VOLUNTEER' + AND p.code IN ('assigned_cases:read', 'service_records:write', 'files:write'); diff --git a/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql b/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql new file mode 100644 index 0000000..9d86e83 --- /dev/null +++ b/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql @@ -0,0 +1,105 @@ +-- AlterTable +ALTER TABLE `service_applications` ADD COLUMN `parent_application_id` BIGINT UNSIGNED NULL, + ADD COLUMN `source_follow_up_task_id` BIGINT UNSIGNED NULL, + MODIFY `source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN', 'FOLLOW_UP') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM'; + +-- AlterTable +ALTER TABLE `service_cases` ADD COLUMN `complete_request_fingerprint` CHAR(64) NULL, + ADD COLUMN `complete_request_id` CHAR(36) NULL; + +-- CreateTable +CREATE TABLE `follow_up_tasks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `follow_up_no` VARCHAR(32) NOT NULL, + `beneficiary_id` BIGINT UNSIGNED NOT NULL, + `case_id` BIGINT UNSIGNED NULL, + `owner_staff_id` BIGINT UNSIGNED NOT NULL, + `due_at` DATETIME(3) NOT NULL, + `follow_up_type` ENUM('SERVICE_COMPLETION', 'KEY_CARE', 'MANUAL') NOT NULL, + `status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'PENDING', + `source` ENUM('AUTO_SERVICE_COMPLETION', 'STAFF_MANUAL', 'KEY_CARE_PLAN') NOT NULL, + `reason_encrypted` TEXT NOT NULL, + `result_code` ENUM('SATISFIED', 'GENERALLY_SATISFIED', 'UNSATISFIED', 'NEED_MORE_HELP', 'UNREACHABLE', 'OTHER') NULL, + `result_note_encrypted` TEXT NULL, + `create_request_id` CHAR(36) NOT NULL, + `create_request_fingerprint` CHAR(64) NOT NULL, + `complete_request_id` CHAR(36) NULL, + `complete_request_fingerprint` CHAR(64) NULL, + `completed_at` DATETIME(3) NULL, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `follow_up_tasks_public_id_key`(`public_id`), + UNIQUE INDEX `follow_up_tasks_follow_up_no_key`(`follow_up_no`), + INDEX `follow_up_tasks_owner_status_due_at_idx`(`owner_staff_id`, `status`, `due_at`), + INDEX `follow_up_tasks_status_due_at_idx`(`status`, `due_at`), + INDEX `follow_up_tasks_beneficiary_due_at_idx`(`beneficiary_id`, `due_at`), + INDEX `follow_up_tasks_case_created_at_idx`(`case_id`, `created_at`), + UNIQUE INDEX `follow_up_tasks_creator_request_key`(`created_by`, `create_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `follow_up_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `follow_up_task_id` BIGINT UNSIGNED NOT NULL, + `from_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NULL, + `to_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `follow_up_status_history_task_created_at_idx`(`follow_up_task_id`, `created_at`), + INDEX `follow_up_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE UNIQUE INDEX `service_applications_source_follow_up_task_id_key` ON `service_applications`(`source_follow_up_task_id`); + +-- CreateIndex +CREATE INDEX `service_applications_parent_submitted_at_idx` ON `service_applications`(`parent_application_id`, `submitted_at`); + +-- CreateIndex +CREATE UNIQUE INDEX `service_cases_complete_request_id_key` ON `service_cases`(`complete_request_id`); + +-- AddForeignKey +ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_source_follow_up_task_id_fkey` FOREIGN KEY (`source_follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_parent_application_id_fkey` FOREIGN KEY (`parent_application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_follow_up_task_id_fkey` FOREIGN KEY (`follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Phase 4 follow-up permissions. Volunteers do not receive access to beneficiary follow-up results. +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('follow_ups:read', '查看回访任务', '按负责人或管理范围查看回访工作台和任务'), +('follow_ups:manage', '创建回访任务', '创建人工或重点关爱回访并在服务完成时生成回访'), +('follow_ups:complete', '完成回访任务', '由任务负责人记录回访结论和继续帮扶授权'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') + AND p.code IN ('follow_ups:read', 'follow_ups:manage', 'follow_ups:complete'); diff --git a/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql b/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql new file mode 100644 index 0000000..24eb258 --- /dev/null +++ b/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql @@ -0,0 +1,267 @@ +-- AlterTable +ALTER TABLE `consents` MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN', 'VOLUNTEER_APPLICATION') NOT NULL; + +-- AlterTable +ALTER TABLE `files` MODIFY `case_id` BIGINT UNSIGNED NULL, + MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER') NOT NULL; + +-- AlterTable +ALTER TABLE `service_cases` ADD COLUMN `project_id` BIGINT UNSIGNED NULL; + +-- CreateTable +CREATE TABLE `volunteers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `consent_id` BIGINT UNSIGNED NOT NULL, + `real_name_encrypted` VARCHAR(512) NOT NULL, + `real_name_hash` CHAR(64) NOT NULL, + `mobile_encrypted` VARCHAR(512) NOT NULL, + `mobile_hash` CHAR(64) NOT NULL, + `region_code` VARCHAR(100) NOT NULL, + `availability_note_encrypted` TEXT NOT NULL, + `status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL DEFAULT 'APPLIED', + `approved_by` BIGINT UNSIGNED NULL, + `approved_at` DATETIME(3) NULL, + `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `volunteers_public_id_key`(`public_id`), + UNIQUE INDEX `volunteers_user_id_key`(`user_id`), + UNIQUE INDEX `volunteers_consent_id_key`(`consent_id`), + INDEX `volunteers_status_created_at_idx`(`status`, `created_at`), + INDEX `volunteers_mobile_hash_idx`(`mobile_hash`), + INDEX `volunteers_region_status_idx`(`region_code`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `volunteer_skills` ( + `volunteer_id` BIGINT UNSIGNED NOT NULL, + `skill_code` VARCHAR(64) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `volunteer_skills_code_idx`(`skill_code`), + PRIMARY KEY (`volunteer_id`, `skill_code`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `volunteer_interests` ( + `volunteer_id` BIGINT UNSIGNED NOT NULL, + `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `volunteer_interests_category_idx`(`category`), + PRIMARY KEY (`volunteer_id`, `category`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `volunteer_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `volunteer_id` BIGINT UNSIGNED NOT NULL, + `request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `from_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NULL, + `to_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `note_encrypted` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `volunteer_status_history_request_id_key`(`request_id`), + INDEX `volunteer_status_history_volunteer_created_at_idx`(`volunteer_id`, `created_at`), + INDEX `volunteer_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `projects` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `project_no` VARCHAR(32) NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL, + `summary` VARCHAR(500) NOT NULL, + `content` TEXT NOT NULL, + `cover_file_id` BIGINT UNSIGNED NULL, + `manager_user_id` BIGINT UNSIGNED NOT NULL, + `start_date` DATE NOT NULL, + `end_date` DATE NOT NULL, + `target_people` INTEGER UNSIGNED NULL, + `status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL DEFAULT 'DRAFT', + `is_public` BOOLEAN NOT NULL DEFAULT false, + `sort_order` INTEGER NOT NULL DEFAULT 0, + `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `projects_public_id_key`(`public_id`), + UNIQUE INDEX `projects_project_no_key`(`project_no`), + UNIQUE INDEX `projects_cover_file_id_key`(`cover_file_id`), + INDEX `projects_public_status_sort_idx`(`status`, `is_public`, `sort_order`, `created_at`), + INDEX `projects_manager_status_idx`(`manager_user_id`, `status`, `created_at`), + UNIQUE INDEX `projects_creator_request_key`(`created_by`, `client_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `project_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `project_id` BIGINT UNSIGNED NOT NULL, + `request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `from_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NULL, + `to_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `project_status_history_request_id_key`(`request_id`), + INDEX `project_status_history_project_created_at_idx`(`project_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `activities` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `activity_no` VARCHAR(32) NOT NULL, + `project_id` BIGINT UNSIGNED NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `title` VARCHAR(200) NOT NULL, + `summary` VARCHAR(500) NOT NULL, + `content` TEXT NOT NULL, + `cover_file_id` BIGINT UNSIGNED NULL, + `start_at` DATETIME(3) NOT NULL, + `end_at` DATETIME(3) NOT NULL, + `registration_deadline` DATETIME(3) NOT NULL, + `location_text` VARCHAR(300) NOT NULL, + `capacity` INTEGER UNSIGNED NULL, + `status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT', + `published_at` DATETIME(3) NULL, + `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `activities_public_id_key`(`public_id`), + UNIQUE INDEX `activities_activity_no_key`(`activity_no`), + UNIQUE INDEX `activities_cover_file_id_key`(`cover_file_id`), + INDEX `activities_status_start_at_idx`(`status`, `start_at`), + INDEX `activities_project_start_at_idx`(`project_id`, `start_at`), + UNIQUE INDEX `activities_creator_request_key`(`created_by`, `client_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `activity_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `activity_id` BIGINT UNSIGNED NOT NULL, + `request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `from_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NULL, + `to_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `activity_status_history_request_id_key`(`request_id`), + INDEX `activity_status_history_activity_created_at_idx`(`activity_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `activity_registrations` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `activity_id` BIGINT UNSIGNED NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `volunteer_id` BIGINT UNSIGNED NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NOT NULL DEFAULT 'PENDING', + `check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NOT NULL DEFAULT 'NOT_CHECKED_IN', + `registered_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `reviewed_by` BIGINT UNSIGNED NULL, + `reviewed_at` DATETIME(3) NULL, + `review_note_encrypted` TEXT NULL, + `checked_in_at` DATETIME(3) NULL, + `checked_in_by` BIGINT UNSIGNED NULL, + `service_minutes` INTEGER UNSIGNED NULL, + `service_minutes_recorded_at` DATETIME(3) NULL, + `service_minutes_recorded_by` BIGINT UNSIGNED NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `activity_registrations_public_id_key`(`public_id`), + INDEX `activity_registrations_activity_status_idx`(`activity_id`, `status`, `registered_at`), + INDEX `activity_registrations_volunteer_registered_idx`(`volunteer_id`, `registered_at`), + UNIQUE INDEX `activity_registrations_activity_user_key`(`activity_id`, `user_id`), + UNIQUE INDEX `activity_registrations_user_request_key`(`user_id`, `client_request_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `activity_registration_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `registration_id` BIGINT UNSIGNED NOT NULL, + `request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `from_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL, + `to_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL, + `from_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL, + `to_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL, + `service_minutes_before` INTEGER UNSIGNED NULL, + `service_minutes_after` INTEGER UNSIGNED NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `action_code` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `activity_registration_history_request_id_key`(`request_id`), + INDEX `registration_history_registration_created_at_idx`(`registration_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX `service_cases_project_created_at_idx` ON `service_cases`(`project_id`, `created_at`); + +ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_approved_by_fkey` FOREIGN KEY (`approved_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteer_skills` ADD CONSTRAINT `volunteer_skills_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteer_interests` ADD CONSTRAINT `volunteer_interests_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `projects` ADD CONSTRAINT `projects_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `projects` ADD CONSTRAINT `projects_manager_user_id_fkey` FOREIGN KEY (`manager_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `projects` ADD CONSTRAINT `projects_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `projects` ADD CONSTRAINT `projects_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activities` ADD CONSTRAINT `activities_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activities` ADD CONSTRAINT `activities_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activities` ADD CONSTRAINT `activities_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activities` ADD CONSTRAINT `activities_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_checked_in_by_fkey` FOREIGN KEY (`checked_in_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_service_minutes_recorded_by_fkey` FOREIGN KEY (`service_minutes_recorded_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_registration_id_fkey` FOREIGN KEY (`registration_id`) REFERENCES `activity_registrations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('volunteers:read', '查看志愿者', '按授权范围查看志愿者脱敏列表与详情'), +('volunteers:review', '审核志愿者', '审核、暂停和恢复志愿者资格'), +('projects:read', '查看公益项目', '按授权范围查看公益项目'), +('projects:manage', '管理公益项目', '创建和维护公益项目及关联工单'), +('projects:publish', '发布公益项目', '执行公益项目发布和完成状态迁移'), +('activities:read', '查看公益活动', '按授权范围查看活动和报名'), +('activities:manage', '管理公益活动', '创建和维护公益活动'), +('activities:publish', '发布公益活动', '执行活动发布及状态迁移'), +('registrations:manage', '管理活动报名', '审核报名、签到并记录服务时长'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') + AND p.code IN ('volunteers:read', 'volunteers:review', 'projects:read', 'projects:manage', 'projects:publish', 'activities:read', 'activities:manage', 'activities:publish', 'registrations:manage'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code = 'STAFF' + AND p.code IN ('volunteers:read', 'projects:read', 'activities:read', 'registrations:manage'); diff --git a/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql b/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql new file mode 100644 index 0000000..d494f74 --- /dev/null +++ b/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql @@ -0,0 +1,120 @@ +-- Phase 6: reviewed public content and durable user notifications. +ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER', 'CONTENT_COVER') NOT NULL; + +CREATE TABLE `content_posts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `client_request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `type` ENUM('ORGANIZATION_INTRO','PUBLIC_WELFARE_NEWS','SERVICE_STORY','NOTICE','FAQ') NOT NULL, + `title` VARCHAR(200) NOT NULL, + `summary` VARCHAR(500) NOT NULL, + `content` TEXT NOT NULL, + `cover_file_id` BIGINT UNSIGNED NULL, + `status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT', + `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `privacy_reviewed_at` DATETIME(3) NULL, + `reviewed_by` BIGINT UNSIGNED NULL, + `reviewed_at` DATETIME(3) NULL, + `published_at` DATETIME(3) NULL, + `created_by` BIGINT UNSIGNED NOT NULL, + `updated_by` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `content_posts_public_id_key` (`public_id`), + UNIQUE INDEX `content_posts_cover_file_id_key` (`cover_file_id`), + UNIQUE INDEX `content_posts_creator_request_key` (`created_by`, `client_request_id`), + INDEX `content_posts_status_type_published_idx` (`status`, `type`, `published_at`), + INDEX `content_posts_creator_status_updated_idx` (`created_by`, `status`, `updated_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `content_post_status_history` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `content_post_id` BIGINT UNSIGNED NOT NULL, + `request_id` CHAR(36) NOT NULL, + `request_fingerprint` CHAR(64) NOT NULL, + `from_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NULL, + `to_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL, + `operator_user_id` BIGINT UNSIGNED NOT NULL, + `reason_code` VARCHAR(100) NOT NULL, + `note_encrypted` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `content_post_status_history_request_id_key` (`request_id`), + INDEX `content_status_history_post_created_idx` (`content_post_id`, `created_at`), + INDEX `content_status_history_operator_created_idx` (`operator_user_id`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `notifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `event_key` VARCHAR(160) NOT NULL, + `type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL, + `title` VARCHAR(120) NOT NULL, + `body` VARCHAR(500) NOT NULL, + `resource_type` VARCHAR(80) NULL, + `resource_public_id` CHAR(36) NULL, + `read_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE INDEX `notifications_public_id_key` (`public_id`), + UNIQUE INDEX `notifications_user_event_key` (`user_id`, `event_key`), + INDEX `notifications_user_read_created_idx` (`user_id`, `read_at`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `notification_preferences` ( + `user_id` BIGINT UNSIGNED NOT NULL, + `type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL, + `in_app_enabled` BOOLEAN NOT NULL DEFAULT true, + `wechat_enabled` BOOLEAN NOT NULL DEFAULT false, + `authorized_at` DATETIME(3) NULL, + `updated_at` DATETIME(3) NOT NULL, + INDEX `notification_preferences_type_wechat_idx` (`type`, `wechat_enabled`), + PRIMARY KEY (`user_id`, `type`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `notification_deliveries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `public_id` CHAR(36) NOT NULL, + `notification_id` BIGINT UNSIGNED NOT NULL, + `channel` ENUM('WECHAT_SUBSCRIPTION') NOT NULL, + `status` ENUM('PENDING','PROCESSING','SENT','FAILED','DEAD') NOT NULL DEFAULT 'PENDING', + `attempt_count` INTEGER UNSIGNED NOT NULL DEFAULT 0, + `next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `last_attempt_at` DATETIME(3) NULL, + `provider_message_id` VARCHAR(160) NULL, + `error_code` VARCHAR(100) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `notification_deliveries_public_id_key` (`public_id`), + UNIQUE INDEX `notification_deliveries_notification_channel_key` (`notification_id`, `channel`), + INDEX `notification_deliveries_status_next_idx` (`status`, `next_attempt_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_post_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `notifications` ADD CONSTRAINT `notifications_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `notification_preferences` ADD CONSTRAINT `notification_preferences_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `notification_deliveries` ADD CONSTRAINT `notification_deliveries_notification_id_fkey` FOREIGN KEY (`notification_id`) REFERENCES `notifications` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('content:read', '查看内容', '查看内容草稿、审核状态及历史'), +('content:manage', '管理内容', '创建和维护内容草稿'), +('content:review', '审核内容', '审核、发布、退回和归档内容'), +('notifications:manage', '管理通知', '查看通知投递状态并安全重试'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p +WHERE r.code IN ('ADMIN','SUPER_ADMIN') + AND p.code IN ('content:read','content:manage','content:review','notifications:manage'); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p +WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code = 'content:read'; diff --git a/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql b/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql new file mode 100644 index 0000000..5b692de --- /dev/null +++ b/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO `permissions` (`code`, `name`, `description`) VALUES +('statistics:read', '查看统计', '查看基于真实业务数据生成的统计指标'), +('exports:create', '创建导出', '创建并下载默认脱敏的受控统计导出'); +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p +WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('statistics:read','exports:create'); +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p +WHERE r.code = 'PROJECT_MANAGER' AND p.code = 'statistics:read'; diff --git a/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql b/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql new file mode 100644 index 0000000..744f87b --- /dev/null +++ b/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `content_post_status_history` + DROP FOREIGN KEY `content_status_history_post_fkey`, + DROP FOREIGN KEY `content_status_history_operator_fkey`, + ADD CONSTRAINT `content_post_status_history_content_post_id_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + ADD CONSTRAINT `content_post_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..7dec767 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "mysql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..2a04b80 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,1203 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "cjs" + importFileExtension = "" +} + +datasource db { + provider = "mysql" +} + +enum UserStatus { + ACTIVE + SUSPENDED + DISABLED +} + +enum UserType { + PUBLIC + STAFF +} + +enum StaffStatus { + ACTIVE + INACTIVE +} + +enum SessionChannel { + WECHAT + ADMIN +} + +enum AuditResult { + SUCCESS + FAILURE +} + +enum ServiceCategory { + OLD_AGE + WOMEN + CHILD + DISABILITY + LIFE_DIFFICULTY + OTHER +} + +enum ApplicationApplyMode { + SELF + FAMILY + COMMUNITY + VOLUNTEER + OTHER +} + +enum ServiceApplicationStatus { + SUBMITTED + PENDING_CONTACT + ASSESSING + REJECTED + OUT_OF_SCOPE + REFERRED + ACCEPTED + SERVICE_PLANNED + IN_SERVICE + SERVICE_COMPLETED + FOLLOW_UP_PENDING + CLOSED + CANCELLED + SUSPENDED +} + +enum ApplicationSource { + WECHAT_MINIPROGRAM + ADMIN + FOLLOW_UP +} + +enum ConsentType { + SERVICE_APPLICATION + BENEFICIARY_GUARDIAN + VOLUNTEER_APPLICATION +} + +enum ConsentGrantedBy { + SELF + APPLICANT + GUARDIAN +} + +enum BeneficiaryGender { + FEMALE + MALE + OTHER + UNDISCLOSED +} + +enum BeneficiaryStatus { + ACTIVE + INACTIVE + ARCHIVED +} + +enum ServiceCasePriority { + LOW + NORMAL + HIGH + URGENT +} + +enum ServiceCaseStatus { + ASSIGNED + IN_SERVICE + SERVICE_COMPLETED + SUSPENDED + CANCELLED +} + +enum CaseAssigneeType { + STAFF + VOLUNTEER +} + +enum CaseAssigneeRole { + OWNER + COLLABORATOR +} + +enum ServiceRecordType { + HOME_VISIT + PHONE_CONTACT + ESCORT + MATERIAL_SUPPORT + ACTIVITY + OTHER +} + +enum BeneficiaryConfirmStatus { + NOT_REQUESTED + CONFIRMED + PARTIALLY_CONFIRMED + DISPUTED +} + +enum FileStatus { + UPLOAD_PENDING + AVAILABLE + QUARANTINED + DELETED +} + +enum FileBusinessType { + SERVICE_RECORD + PROJECT_COVER + ACTIVITY_COVER + CONTENT_COVER +} + +enum ContentPostType { + ORGANIZATION_INTRO + PUBLIC_WELFARE_NEWS + SERVICE_STORY + NOTICE + FAQ +} + +enum ContentPostStatus { + DRAFT + PENDING_REVIEW + PUBLISHED + ARCHIVED +} + +enum NotificationType { + APPLICATION_SUBMITTED + APPLICATION_ACCEPTED + SERVICE_ARRANGED + SERVICE_COMPLETED + FOLLOW_UP_PENDING + ACTIVITY_REGISTRATION_RESULT +} + +enum NotificationChannel { + WECHAT_SUBSCRIPTION +} + +enum NotificationDeliveryStatus { + PENDING + PROCESSING + SENT + FAILED + DEAD +} + +enum VolunteerStatus { + APPLIED + APPROVED + REJECTED + SUSPENDED + INACTIVE +} + +enum ProjectStatus { + DRAFT + PUBLISHED + COMPLETED +} + +enum ActivityStatus { + DRAFT + PUBLISHED + REGISTRATION_CLOSED + IN_PROGRESS + COMPLETED + CANCELLED +} + +enum ActivityRegistrationStatus { + PENDING + APPROVED + REJECTED + CANCELLED +} + +enum ActivityCheckInStatus { + NOT_CHECKED_IN + CHECKED_IN + ABSENT +} + +enum FollowUpType { + SERVICE_COMPLETION + KEY_CARE + MANUAL +} + +enum FollowUpStatus { + PENDING + COMPLETED + CANCELLED +} + +enum FollowUpResultCode { + SATISFIED + GENERALLY_SATISFIED + UNSATISFIED + NEED_MORE_HELP + UNREACHABLE + OTHER +} + +enum FollowUpSource { + AUTO_SERVICE_COMPLETION + STAFF_MANUAL + KEY_CARE_PLAN +} + +model User { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + status UserStatus @default(ACTIVE) + userType UserType @map("user_type") + mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512) + mobileHash String? @unique @map("mobile_hash") @db.Char(64) + nickname String? @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.VarChar(512) + lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + wechatIdentities WechatIdentity[] + staffProfile StaffProfile? + adminCredential AdminCredential? + sessions AuthSession[] + userRoles UserRole[] + auditLogs AuditLog[] @relation("AuditActor") + assignedRoles UserRole[] @relation("UserRoleAssigner") + serviceApplications ServiceApplication[] @relation("ApplicationApplicant") + applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator") + consents Consent[] + createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator") + updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater") + ownedServiceCases ServiceCase[] @relation("CaseOwner") + createdServiceCases ServiceCase[] @relation("CaseCreator") + updatedServiceCases ServiceCase[] @relation("CaseUpdater") + caseAssignments CaseAssignee[] + caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator") + createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator") + updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater") + uploadedFiles File[] @relation("FileUploader") + assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner") + assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner") + revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser") + ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner") + createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator") + updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater") + followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator") + volunteerProfile Volunteer? + reviewedVolunteers Volunteer[] @relation("VolunteerReviewer") + createdVolunteers Volunteer[] @relation("VolunteerCreator") + updatedVolunteers Volunteer[] @relation("VolunteerUpdater") + volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator") + managedProjects Project[] @relation("ProjectManager") + createdProjects Project[] @relation("ProjectCreator") + updatedProjects Project[] @relation("ProjectUpdater") + projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator") + createdActivities Activity[] @relation("ActivityCreator") + updatedActivities Activity[] @relation("ActivityUpdater") + activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator") + activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser") + reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer") + checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator") + recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator") + registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator") + createdContentPosts ContentPost[] @relation("ContentCreator") + updatedContentPosts ContentPost[] @relation("ContentUpdater") + reviewedContentPosts ContentPost[] @relation("ContentReviewer") + contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator") + notifications Notification[] + notificationPreferences NotificationPreference[] + + @@map("users") +} + +model WechatIdentity { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + appid String @db.VarChar(64) + openidCiphertext String @map("openid_ciphertext") @db.VarChar(512) + openidHash String @map("openid_hash") @db.Char(64) + unionidCiphertext String? @map("unionid_ciphertext") @db.VarChar(512) + unionidHash String? @map("unionid_hash") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + @@unique([appid, openidHash], map: "wechat_identities_appid_openid_hash_key") + @@index([userId], map: "wechat_identities_user_id_idx") + @@map("wechat_identities") +} + +model StaffProfile { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + employeeNo String? @unique @map("employee_no") @db.VarChar(50) + displayName String @map("display_name") @db.VarChar(100) + department String? @db.VarChar(100) + status StaffStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + @@map("staff_profiles") +} + +model AdminCredential { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + username String @unique @db.VarChar(64) + passwordHash String @map("password_hash") @db.VarChar(255) + failedLoginCount Int @default(0) @map("failed_login_count") @db.UnsignedInt + lockedUntil DateTime? @map("locked_until") @db.DateTime(3) + passwordChangedAt DateTime @default(now()) @map("password_changed_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + @@map("admin_credentials") +} + +model AuthSession { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + userId BigInt @map("user_id") @db.UnsignedBigInt + channel SessionChannel + tokenHash String @unique @map("token_hash") @db.Char(64) + expiresAt DateTime @map("expires_at") @db.DateTime(3) + lastUsedAt DateTime? @map("last_used_at") @db.DateTime(3) + revokedAt DateTime? @map("revoked_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + @@index([userId, channel, revokedAt, expiresAt], map: "auth_sessions_user_channel_status_idx") + @@index([expiresAt], map: "auth_sessions_expires_at_idx") + @@map("auth_sessions") +} + +model Role { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + code String @unique @db.VarChar(64) + name String @db.VarChar(100) + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + userRoles UserRole[] + rolePermissions RolePermission[] + + @@map("roles") +} + +model Permission { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(100) + name String @db.VarChar(100) + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + rolePermissions RolePermission[] + + @@map("permissions") +} + +model UserRole { + userId BigInt @map("user_id") @db.UnsignedBigInt + roleId BigInt @map("role_id") @db.UnsignedBigInt + assignedBy BigInt? @map("assigned_by") @db.UnsignedBigInt + assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) + assigner User? @relation("UserRoleAssigner", fields: [assignedBy], references: [id], onDelete: Restrict) + + @@id([userId, roleId]) + @@index([roleId], map: "user_roles_role_id_idx") + @@index([assignedBy], map: "user_roles_assigned_by_idx") + @@map("user_roles") +} + +model RolePermission { + roleId BigInt @map("role_id") @db.UnsignedBigInt + permissionId BigInt @map("permission_id") @db.UnsignedBigInt + role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Restrict) + + @@id([roleId, permissionId]) + @@index([permissionId], map: "role_permissions_permission_id_idx") + @@map("role_permissions") +} + +model AuditLog { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + requestId String @map("request_id") @db.Char(36) + actorUserId BigInt? @map("actor_user_id") @db.UnsignedBigInt + actorRole String? @map("actor_role") @db.VarChar(64) + action String @db.VarChar(100) + resourceType String? @map("resource_type") @db.VarChar(100) + resourcePublicId String? @map("resource_public_id") @db.VarChar(100) + result AuditResult + metadataJson Json? @map("metadata_json") + ipHash String? @map("ip_hash") @db.Char(64) + userAgentSummary String? @map("user_agent_summary") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + actor User? @relation("AuditActor", fields: [actorUserId], references: [id], onDelete: Restrict) + + @@index([actorUserId, createdAt], map: "audit_logs_actor_created_at_idx") + @@index([resourceType, resourcePublicId, createdAt], map: "audit_logs_resource_created_at_idx") + @@index([action, createdAt], map: "audit_logs_action_created_at_idx") + @@map("audit_logs") +} + +model Consent { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + userId BigInt? @map("user_id") @db.UnsignedBigInt + beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt + consentType ConsentType @map("consent_type") + version String @db.VarChar(32) + grantedBy ConsentGrantedBy @map("granted_by") + grantedAt DateTime @map("granted_at") @db.DateTime(3) + revokedAt DateTime? @map("revoked_at") @db.DateTime(3) + evidenceJson Json? @map("evidence_json") + ipHash String? @map("ip_hash") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + user User? @relation(fields: [userId], references: [id], onDelete: Restrict) + beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + application ServiceApplication? + beneficiaryContact BeneficiaryContact? + volunteer Volunteer? + + @@index([userId, consentType, grantedAt], map: "consents_user_type_granted_at_idx") + @@index([beneficiaryId, consentType, grantedAt], map: "consents_beneficiary_type_granted_at_idx") + @@map("consents") +} + +model ServiceApplication { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + applicationNo String @unique @map("application_no") @db.VarChar(32) + applicantUserId BigInt @map("applicant_user_id") @db.UnsignedBigInt + beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt + consentId BigInt @unique @map("consent_id") @db.UnsignedBigInt + sourceFollowUpTaskId BigInt? @unique @map("source_follow_up_task_id") @db.UnsignedBigInt + parentApplicationId BigInt? @map("parent_application_id") @db.UnsignedBigInt + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + applyMode ApplicationApplyMode @map("apply_mode") + serviceCategory ServiceCategory @map("service_category") + beneficiaryNameEncrypted String @map("beneficiary_name_encrypted") @db.VarChar(512) + contactMobileEncrypted String @map("contact_mobile_encrypted") @db.VarChar(512) + contactMobileHash String @map("contact_mobile_hash") @db.Char(64) + regionCode String @map("region_code") @db.VarChar(100) + descriptionEncrypted String @map("description_encrypted") @db.Text + preferredContactTime String? @map("preferred_contact_time") @db.VarChar(100) + needsHomeVisit Boolean? @map("needs_home_visit") + mobilityDifficulty Boolean? @map("mobility_difficulty") + status ServiceApplicationStatus @default(SUBMITTED) + source ApplicationSource @default(WECHAT_MINIPROGRAM) + submittedAt DateTime @map("submitted_at") @db.DateTime(3) + acceptedAt DateTime? @map("accepted_at") @db.DateTime(3) + closedAt DateTime? @map("closed_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + applicant User @relation("ApplicationApplicant", fields: [applicantUserId], references: [id], onDelete: Restrict) + beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + consent Consent @relation(fields: [consentId], references: [id], onDelete: Restrict) + statusHistory ServiceApplicationStatusHistory[] + serviceCase ServiceCase? + sourceFollowUpTask FollowUpTask? @relation("FollowUpContinuedApplication", fields: [sourceFollowUpTaskId], references: [id], onDelete: Restrict) + parentApplication ServiceApplication? @relation("ContinuedApplications", fields: [parentApplicationId], references: [id], onDelete: Restrict) + continuedApplications ServiceApplication[] @relation("ContinuedApplications") + + @@unique([applicantUserId, clientRequestId], map: "service_applications_applicant_client_request_key") + @@index([applicantUserId, submittedAt], map: "service_applications_applicant_submitted_at_idx") + @@index([status, submittedAt], map: "service_applications_status_submitted_at_idx") + @@index([serviceCategory, submittedAt], map: "service_applications_category_submitted_at_idx") + @@index([contactMobileHash], map: "service_applications_contact_mobile_hash_idx") + @@index([beneficiaryId, submittedAt], map: "service_applications_beneficiary_submitted_at_idx") + @@index([parentApplicationId, submittedAt], map: "service_applications_parent_submitted_at_idx") + @@map("service_applications") +} + +model ServiceApplicationStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + applicationId BigInt @map("application_id") @db.UnsignedBigInt + fromStatus ServiceApplicationStatus? @map("from_status") + toStatus ServiceApplicationStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + application ServiceApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict) + operator User @relation("ApplicationStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([applicationId, createdAt], map: "application_status_history_application_created_at_idx") + @@index([operatorUserId, createdAt], map: "application_status_history_operator_created_at_idx") + @@map("service_application_status_history") +} + +model BeneficiaryProfile { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512) + displayNameHash String @map("display_name_hash") @db.Char(64) + gender BeneficiaryGender? + birthYear Int? @map("birth_year") @db.UnsignedSmallInt + mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512) + mobileHash String? @map("mobile_hash") @db.Char(64) + regionCode String @map("region_code") @db.VarChar(100) + addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024) + primaryCategory ServiceCategory @map("primary_category") + keyCareFlag Boolean @default(false) @map("key_care_flag") + isMinor Boolean @default(false) @map("is_minor") + status BeneficiaryStatus @default(ACTIVE) + notesEncrypted String? @map("notes_encrypted") @db.Text + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + contacts BeneficiaryContact[] + beneficiaryTags BeneficiaryTag[] + applications ServiceApplication[] + serviceCases ServiceCase[] + serviceRecords ServiceRecord[] + consents Consent[] + followUpTasks FollowUpTask[] + + @@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx") + @@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx") + @@index([primaryCategory, status, createdAt], map: "beneficiary_profiles_category_status_created_at_idx") + @@index([keyCareFlag, status, createdAt], map: "beneficiary_profiles_key_care_status_created_at_idx") + @@map("beneficiary_profiles") +} + +model BeneficiaryContact { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt + nameEncrypted String @map("name_encrypted") @db.VarChar(512) + relation String @db.VarChar(50) + mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512) + mobileHash String @map("mobile_hash") @db.Char(64) + isGuardian Boolean @default(false) @map("is_guardian") + isEmergencyContact Boolean @default(false) @map("is_emergency_contact") + consentRecordId BigInt? @unique @map("consent_record_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + removedAt DateTime? @map("removed_at") @db.DateTime(3) + beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + consentRecord Consent? @relation(fields: [consentRecordId], references: [id], onDelete: Restrict) + + @@index([beneficiaryId, removedAt, isGuardian], map: "beneficiary_contacts_beneficiary_active_guardian_idx") + @@index([mobileHash], map: "beneficiary_contacts_mobile_hash_idx") + @@map("beneficiary_contacts") +} + +model Tag { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + code String @unique @db.VarChar(64) + name String @db.VarChar(50) + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + beneficiaryTags BeneficiaryTag[] + + @@map("tags") +} + +model BeneficiaryTag { + beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt + tagId BigInt @map("tag_id") @db.UnsignedBigInt + assignedById BigInt @map("assigned_by") @db.UnsignedBigInt + assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3) + beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Restrict) + assigner User @relation("BeneficiaryTagAssigner", fields: [assignedById], references: [id], onDelete: Restrict) + + @@id([beneficiaryId, tagId]) + @@index([tagId], map: "beneficiary_tags_tag_id_idx") + @@map("beneficiary_tags") +} + +model ServiceCase { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + caseNo String @unique @map("case_no") @db.VarChar(32) + applicationId BigInt @unique @map("application_id") @db.UnsignedBigInt + acceptRequestId String @map("accept_request_id") @db.Char(36) + acceptRequestFingerprint String @map("accept_request_fingerprint") @db.Char(64) + beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt + projectId BigInt? @map("project_id") @db.UnsignedBigInt + ownerStaffId BigInt @map("owner_staff_id") @db.UnsignedBigInt + category ServiceCategory + priority ServiceCasePriority @default(NORMAL) + status ServiceCaseStatus @default(ASSIGNED) + plannedStartAt DateTime? @map("planned_start_at") @db.DateTime(3) + plannedEndAt DateTime? @map("planned_end_at") @db.DateTime(3) + serviceLocationEncrypted String? @map("service_location_encrypted") @db.VarChar(1024) + summaryEncrypted String @map("summary_encrypted") @db.Text + resultSummaryEncrypted String? @map("result_summary_encrypted") @db.Text + followUpPlanEncrypted String? @map("follow_up_plan_encrypted") @db.Text + completedAt DateTime? @map("completed_at") @db.DateTime(3) + completeRequestId String? @unique @map("complete_request_id") @db.Char(36) + completeRequestFingerprint String? @map("complete_request_fingerprint") @db.Char(64) + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + application ServiceApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict) + beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: Restrict) + owner User @relation("CaseOwner", fields: [ownerStaffId], references: [id], onDelete: Restrict) + creator User @relation("CaseCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("CaseUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + assignees CaseAssignee[] + statusHistory ServiceCaseStatusHistory[] + serviceRecords ServiceRecord[] + files File[] + followUpTasks FollowUpTask[] + + @@unique([createdById, acceptRequestId], map: "service_cases_creator_accept_request_key") + @@index([ownerStaffId, status, plannedStartAt], map: "service_cases_owner_status_planned_idx") + @@index([beneficiaryId, createdAt], map: "service_cases_beneficiary_created_at_idx") + @@index([projectId, createdAt], map: "service_cases_project_created_at_idx") + @@index([status, createdAt], map: "service_cases_status_created_at_idx") + @@map("service_cases") +} + +model CaseAssignee { + caseId BigInt @map("case_id") @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + assigneeType CaseAssigneeType @map("assignee_type") + role CaseAssigneeRole + assignedById BigInt @map("assigned_by") @db.UnsignedBigInt + assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3) + removedAt DateTime? @map("removed_at") @db.DateTime(3) + serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + assigner User @relation("CaseAssigneeAssigner", fields: [assignedById], references: [id], onDelete: Restrict) + + @@id([caseId, userId]) + @@index([userId, removedAt, assignedAt], map: "case_assignees_user_active_assigned_idx") + @@map("case_assignees") +} + +model ServiceCaseStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + caseId BigInt @map("case_id") @db.UnsignedBigInt + fromStatus ServiceCaseStatus? @map("from_status") + toStatus ServiceCaseStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict) + operator User @relation("CaseStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([caseId, createdAt], map: "case_status_history_case_created_at_idx") + @@index([operatorUserId, createdAt], map: "case_status_history_operator_created_at_idx") + @@map("service_case_status_history") +} + +model ServiceRecord { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + caseId BigInt @map("case_id") @db.UnsignedBigInt + beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + serviceStartAt DateTime @map("service_start_at") @db.DateTime(3) + serviceEndAt DateTime? @map("service_end_at") @db.DateTime(3) + serviceType ServiceRecordType @map("service_type") + contentEncrypted String @map("content_encrypted") @db.Text + resultEncrypted String @map("result_encrypted") @db.Text + followUpSuggestionEncrypted String? @map("follow_up_suggestion_encrypted") @db.Text + beneficiaryConfirmStatus BeneficiaryConfirmStatus @default(NOT_REQUESTED) @map("beneficiary_confirm_status") + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + revision Int @default(1) @db.UnsignedInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict) + beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + creator User @relation("ServiceRecordCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("ServiceRecordUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + attachments ServiceRecordFile[] + revisions ServiceRecordRevision[] + + @@unique([createdById, clientRequestId], map: "service_records_creator_client_request_key") + @@index([caseId, serviceStartAt], map: "service_records_case_start_at_idx") + @@index([beneficiaryId, serviceStartAt], map: "service_records_beneficiary_start_at_idx") + @@map("service_records") +} + +model ServiceRecordRevision { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt + revision Int @db.UnsignedInt + serviceStartAt DateTime @map("service_start_at") @db.DateTime(3) + serviceEndAt DateTime? @map("service_end_at") @db.DateTime(3) + serviceType ServiceRecordType @map("service_type") + contentEncrypted String @map("content_encrypted") @db.Text + resultEncrypted String @map("result_encrypted") @db.Text + followUpSuggestionEncrypted String? @map("follow_up_suggestion_encrypted") @db.Text + beneficiaryConfirmStatus BeneficiaryConfirmStatus @map("beneficiary_confirm_status") + revisedById BigInt @map("revised_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Restrict) + reviser User @relation("ServiceRecordReviser", fields: [revisedById], references: [id], onDelete: Restrict) + + @@unique([serviceRecordId, revision], map: "service_record_revisions_record_revision_key") + @@index([revisedById, createdAt], map: "service_record_revisions_reviser_created_at_idx") + @@map("service_record_revisions") +} + +model File { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + caseId BigInt? @map("case_id") @db.UnsignedBigInt + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + storageProvider String @map("storage_provider") @db.VarChar(50) + bucket String @db.VarChar(255) + objectKey String @unique @map("object_key") @db.VarChar(512) + originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024) + mimeType String @map("mime_type") @db.VarChar(100) + size BigInt @db.UnsignedBigInt + sha256 String? @db.Char(64) + businessType FileBusinessType @map("business_type") + uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt + status FileStatus @default(UPLOAD_PENDING) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict) + uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict) + serviceRecords ServiceRecordFile[] + projectCover Project? @relation("ProjectCover") + activityCover Activity? @relation("ActivityCover") + contentCover ContentPost? @relation("ContentCover") + + @@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key") + @@index([caseId, status, createdAt], map: "files_case_status_created_at_idx") + @@index([uploaderUserId, status, createdAt], map: "files_uploader_status_created_at_idx") + @@map("files") +} + +model ServiceRecordFile { + serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt + fileId BigInt @map("file_id") @db.UnsignedBigInt + attachedAt DateTime @default(now()) @map("attached_at") @db.DateTime(3) + serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Restrict) + file File @relation(fields: [fileId], references: [id], onDelete: Restrict) + + @@id([serviceRecordId, fileId]) + @@index([fileId], map: "service_record_files_file_id_idx") + @@map("service_record_files") +} + +model FollowUpTask { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + followUpNo String @unique @map("follow_up_no") @db.VarChar(32) + beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt + caseId BigInt? @map("case_id") @db.UnsignedBigInt + ownerStaffId BigInt @map("owner_staff_id") @db.UnsignedBigInt + dueAt DateTime @map("due_at") @db.DateTime(3) + followUpType FollowUpType @map("follow_up_type") + status FollowUpStatus @default(PENDING) + source FollowUpSource + reasonEncrypted String @map("reason_encrypted") @db.Text + resultCode FollowUpResultCode? @map("result_code") + resultNoteEncrypted String? @map("result_note_encrypted") @db.Text + createRequestId String @map("create_request_id") @db.Char(36) + createRequestFingerprint String @map("create_request_fingerprint") @db.Char(64) + completeRequestId String? @map("complete_request_id") @db.Char(36) + completeRequestFingerprint String? @map("complete_request_fingerprint") @db.Char(64) + completedAt DateTime? @map("completed_at") @db.DateTime(3) + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict) + serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict) + owner User @relation("FollowUpOwner", fields: [ownerStaffId], references: [id], onDelete: Restrict) + creator User @relation("FollowUpCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("FollowUpUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + statusHistory FollowUpStatusHistory[] + continuedApplication ServiceApplication? @relation("FollowUpContinuedApplication") + + @@unique([createdById, createRequestId], map: "follow_up_tasks_creator_request_key") + @@index([ownerStaffId, status, dueAt], map: "follow_up_tasks_owner_status_due_at_idx") + @@index([status, dueAt], map: "follow_up_tasks_status_due_at_idx") + @@index([beneficiaryId, dueAt], map: "follow_up_tasks_beneficiary_due_at_idx") + @@index([caseId, createdAt], map: "follow_up_tasks_case_created_at_idx") + @@map("follow_up_tasks") +} + +model FollowUpStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + followUpTaskId BigInt @map("follow_up_task_id") @db.UnsignedBigInt + fromStatus FollowUpStatus? @map("from_status") + toStatus FollowUpStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + followUpTask FollowUpTask @relation(fields: [followUpTaskId], references: [id], onDelete: Restrict) + operator User @relation("FollowUpStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([followUpTaskId, createdAt], map: "follow_up_status_history_task_created_at_idx") + @@index([operatorUserId, createdAt], map: "follow_up_status_history_operator_created_at_idx") + @@map("follow_up_status_history") +} + +model Volunteer { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + consentId BigInt @unique @map("consent_id") @db.UnsignedBigInt + realNameEncrypted String @map("real_name_encrypted") @db.VarChar(512) + realNameHash String @map("real_name_hash") @db.Char(64) + mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512) + mobileHash String @map("mobile_hash") @db.Char(64) + regionCode String @map("region_code") @db.VarChar(100) + availabilityNoteEncrypted String @map("availability_note_encrypted") @db.Text + status VolunteerStatus @default(APPLIED) + approvedById BigInt? @map("approved_by") @db.UnsignedBigInt + approvedAt DateTime? @map("approved_at") @db.DateTime(3) + revision Int @default(1) @db.UnsignedInt + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + consent Consent @relation(fields: [consentId], references: [id], onDelete: Restrict) + reviewer User? @relation("VolunteerReviewer", fields: [approvedById], references: [id], onDelete: Restrict) + creator User @relation("VolunteerCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("VolunteerUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + skills VolunteerSkill[] + interests VolunteerInterest[] + statusHistory VolunteerStatusHistory[] + registrations ActivityRegistration[] + + @@index([status, createdAt], map: "volunteers_status_created_at_idx") + @@index([mobileHash], map: "volunteers_mobile_hash_idx") + @@index([regionCode, status], map: "volunteers_region_status_idx") + @@map("volunteers") +} + +model VolunteerSkill { + volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt + skillCode String @map("skill_code") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict) + + @@id([volunteerId, skillCode]) + @@index([skillCode], map: "volunteer_skills_code_idx") + @@map("volunteer_skills") +} + +model VolunteerInterest { + volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt + category ServiceCategory + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict) + + @@id([volunteerId, category]) + @@index([category], map: "volunteer_interests_category_idx") + @@map("volunteer_interests") +} + +model VolunteerStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt + requestId String @unique @map("request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + fromStatus VolunteerStatus? @map("from_status") + toStatus VolunteerStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + noteEncrypted String? @map("note_encrypted") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict) + operator User @relation("VolunteerStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([volunteerId, createdAt], map: "volunteer_status_history_volunteer_created_at_idx") + @@index([operatorUserId, createdAt], map: "volunteer_status_history_operator_created_at_idx") + @@map("volunteer_status_history") +} + +model Project { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + projectNo String @unique @map("project_no") @db.VarChar(32) + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + name String @db.VarChar(200) + category ServiceCategory + summary String @db.VarChar(500) + content String @db.Text + coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt + managerUserId BigInt @map("manager_user_id") @db.UnsignedBigInt + startDate DateTime @map("start_date") @db.Date + endDate DateTime @map("end_date") @db.Date + targetPeople Int? @map("target_people") @db.UnsignedInt + status ProjectStatus @default(DRAFT) + isPublic Boolean @default(false) @map("is_public") + sortOrder Int @default(0) @map("sort_order") + revision Int @default(1) @db.UnsignedInt + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + coverFile File? @relation("ProjectCover", fields: [coverFileId], references: [id], onDelete: Restrict) + manager User @relation("ProjectManager", fields: [managerUserId], references: [id], onDelete: Restrict) + creator User @relation("ProjectCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("ProjectUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + statusHistory ProjectStatusHistory[] + activities Activity[] + serviceCases ServiceCase[] + + @@unique([createdById, clientRequestId], map: "projects_creator_request_key") + @@index([status, isPublic, sortOrder, createdAt], map: "projects_public_status_sort_idx") + @@index([managerUserId, status, createdAt], map: "projects_manager_status_idx") + @@map("projects") +} + +model ProjectStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + projectId BigInt @map("project_id") @db.UnsignedBigInt + requestId String @unique @map("request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + fromStatus ProjectStatus? @map("from_status") + toStatus ProjectStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + project Project @relation(fields: [projectId], references: [id], onDelete: Restrict) + operator User @relation("ProjectStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([projectId, createdAt], map: "project_status_history_project_created_at_idx") + @@map("project_status_history") +} + +model Activity { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + activityNo String @unique @map("activity_no") @db.VarChar(32) + projectId BigInt? @map("project_id") @db.UnsignedBigInt + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + title String @db.VarChar(200) + summary String @db.VarChar(500) + content String @db.Text + coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt + startAt DateTime @map("start_at") @db.DateTime(3) + endAt DateTime @map("end_at") @db.DateTime(3) + registrationDeadline DateTime @map("registration_deadline") @db.DateTime(3) + locationText String @map("location_text") @db.VarChar(300) + capacity Int? @db.UnsignedInt + status ActivityStatus @default(DRAFT) + publishedAt DateTime? @map("published_at") @db.DateTime(3) + revision Int @default(1) @db.UnsignedInt + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + project Project? @relation(fields: [projectId], references: [id], onDelete: Restrict) + coverFile File? @relation("ActivityCover", fields: [coverFileId], references: [id], onDelete: Restrict) + creator User @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("ActivityUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + statusHistory ActivityStatusHistory[] + registrations ActivityRegistration[] + + @@unique([createdById, clientRequestId], map: "activities_creator_request_key") + @@index([status, startAt], map: "activities_status_start_at_idx") + @@index([projectId, startAt], map: "activities_project_start_at_idx") + @@map("activities") +} + +model ActivityStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + activityId BigInt @map("activity_id") @db.UnsignedBigInt + requestId String @unique @map("request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + fromStatus ActivityStatus? @map("from_status") + toStatus ActivityStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + activity Activity @relation(fields: [activityId], references: [id], onDelete: Restrict) + operator User @relation("ActivityStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([activityId, createdAt], map: "activity_status_history_activity_created_at_idx") + @@map("activity_status_history") +} + +model ActivityRegistration { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + activityId BigInt @map("activity_id") @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + status ActivityRegistrationStatus @default(PENDING) + checkInStatus ActivityCheckInStatus @default(NOT_CHECKED_IN) @map("check_in_status") + registeredAt DateTime @default(now()) @map("registered_at") @db.DateTime(3) + reviewedById BigInt? @map("reviewed_by") @db.UnsignedBigInt + reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) + reviewNoteEncrypted String? @map("review_note_encrypted") @db.Text + checkedInAt DateTime? @map("checked_in_at") @db.DateTime(3) + checkedInById BigInt? @map("checked_in_by") @db.UnsignedBigInt + serviceMinutes Int? @map("service_minutes") @db.UnsignedInt + serviceMinutesRecordedAt DateTime? @map("service_minutes_recorded_at") @db.DateTime(3) + serviceMinutesRecordedById BigInt? @map("service_minutes_recorded_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + activity Activity @relation(fields: [activityId], references: [id], onDelete: Restrict) + user User @relation("ActivityRegistrationUser", fields: [userId], references: [id], onDelete: Restrict) + volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict) + reviewer User? @relation("RegistrationReviewer", fields: [reviewedById], references: [id], onDelete: Restrict) + checkInOperator User? @relation("RegistrationCheckInOperator", fields: [checkedInById], references: [id], onDelete: Restrict) + serviceMinutesOperator User? @relation("RegistrationMinutesOperator", fields: [serviceMinutesRecordedById], references: [id], onDelete: Restrict) + history ActivityRegistrationHistory[] + + @@unique([activityId, userId], map: "activity_registrations_activity_user_key") + @@unique([userId, clientRequestId], map: "activity_registrations_user_request_key") + @@index([activityId, status, registeredAt], map: "activity_registrations_activity_status_idx") + @@index([volunteerId, registeredAt], map: "activity_registrations_volunteer_registered_idx") + @@map("activity_registrations") +} + +model ActivityRegistrationHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + registrationId BigInt @map("registration_id") @db.UnsignedBigInt + requestId String @unique @map("request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + fromStatus ActivityRegistrationStatus? @map("from_status") + toStatus ActivityRegistrationStatus? @map("to_status") + fromCheckInStatus ActivityCheckInStatus? @map("from_check_in_status") + toCheckInStatus ActivityCheckInStatus? @map("to_check_in_status") + serviceMinutesBefore Int? @map("service_minutes_before") @db.UnsignedInt + serviceMinutesAfter Int? @map("service_minutes_after") @db.UnsignedInt + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + actionCode String @map("action_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + registration ActivityRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict) + operator User @relation("RegistrationHistoryOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([registrationId, createdAt], map: "registration_history_registration_created_at_idx") + @@map("activity_registration_history") +} + +model ContentPost { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + clientRequestId String @map("client_request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + type ContentPostType + title String @db.VarChar(200) + summary String @db.VarChar(500) + content String @db.Text + coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt + status ContentPostStatus @default(DRAFT) + revision Int @default(1) @db.UnsignedInt + privacyReviewedAt DateTime? @map("privacy_reviewed_at") @db.DateTime(3) + reviewedById BigInt? @map("reviewed_by") @db.UnsignedBigInt + reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) + publishedAt DateTime? @map("published_at") @db.DateTime(3) + createdById BigInt @map("created_by") @db.UnsignedBigInt + updatedById BigInt @map("updated_by") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + coverFile File? @relation("ContentCover", fields: [coverFileId], references: [id], onDelete: Restrict) + creator User @relation("ContentCreator", fields: [createdById], references: [id], onDelete: Restrict) + updater User @relation("ContentUpdater", fields: [updatedById], references: [id], onDelete: Restrict) + reviewer User? @relation("ContentReviewer", fields: [reviewedById], references: [id], onDelete: Restrict) + statusHistory ContentPostStatusHistory[] + + @@unique([createdById, clientRequestId], map: "content_posts_creator_request_key") + @@index([status, type, publishedAt], map: "content_posts_status_type_published_idx") + @@index([createdById, status, updatedAt], map: "content_posts_creator_status_updated_idx") + @@map("content_posts") +} + +model ContentPostStatusHistory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + contentPostId BigInt @map("content_post_id") @db.UnsignedBigInt + requestId String @unique @map("request_id") @db.Char(36) + requestFingerprint String @map("request_fingerprint") @db.Char(64) + fromStatus ContentPostStatus? @map("from_status") + toStatus ContentPostStatus @map("to_status") + operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt + reasonCode String @map("reason_code") @db.VarChar(100) + noteEncrypted String? @map("note_encrypted") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + contentPost ContentPost @relation(fields: [contentPostId], references: [id], onDelete: Restrict) + operator User @relation("ContentStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict) + + @@index([contentPostId, createdAt], map: "content_status_history_post_created_idx") + @@index([operatorUserId, createdAt], map: "content_status_history_operator_created_idx") + @@map("content_post_status_history") +} + +model Notification { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + userId BigInt @map("user_id") @db.UnsignedBigInt + eventKey String @map("event_key") @db.VarChar(160) + type NotificationType + title String @db.VarChar(120) + body String @db.VarChar(500) + resourceType String? @map("resource_type") @db.VarChar(80) + resourcePublicId String? @map("resource_public_id") @db.Char(36) + readAt DateTime? @map("read_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + deliveries NotificationDelivery[] + + @@unique([userId, eventKey], map: "notifications_user_event_key") + @@index([userId, readAt, createdAt], map: "notifications_user_read_created_idx") + @@map("notifications") +} + +model NotificationPreference { + userId BigInt @map("user_id") @db.UnsignedBigInt + type NotificationType + inAppEnabled Boolean @default(true) @map("in_app_enabled") + wechatEnabled Boolean @default(false) @map("wechat_enabled") + authorizedAt DateTime? @map("authorized_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + @@id([userId, type]) + @@index([type, wechatEnabled], map: "notification_preferences_type_wechat_idx") + @@map("notification_preferences") +} + +model NotificationDelivery { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + publicId String @unique @map("public_id") @db.Char(36) + notificationId BigInt @map("notification_id") @db.UnsignedBigInt + channel NotificationChannel + status NotificationDeliveryStatus @default(PENDING) + attemptCount Int @default(0) @map("attempt_count") @db.UnsignedInt + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") @db.DateTime(3) + lastAttemptAt DateTime? @map("last_attempt_at") @db.DateTime(3) + providerMessageId String? @map("provider_message_id") @db.VarChar(160) + errorCode String? @map("error_code") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Restrict) + + @@unique([notificationId, channel], map: "notification_deliveries_notification_channel_key") + @@index([status, nextAttemptAt], map: "notification_deliveries_status_next_idx") + @@map("notification_deliveries") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..24b0cf1 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,62 @@ +import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; + +import { AuditModule } from './audit/audit.module'; +import { AuthModule } from './auth/auth.module'; +import { CaseManagementModule } from './case-management/case-management.module'; +import { AuthenticationGuard } from './auth/authentication.guard'; +import { ApiExceptionFilter } from './common/api-exception.filter'; +import { ApiResponseInterceptor } from './common/api-response.interceptor'; +import { RequestIdMiddleware } from './common/request-id.middleware'; +import { AppConfigModule } from './config/app-config.module'; +import { DatabaseModule } from './database/database.module'; +import { HealthController } from './health/health.controller'; +import { HealthService } from './health/health.service'; +import { FollowUpsModule } from './follow-ups/follow-ups.module'; +import { PermissionGuard } from './permissions/permission.guard'; +import { PermissionsModule } from './permissions/permissions.module'; +import { ParticipationModule } from './participation/participation.module'; +import { SecurityModule } from './security/security.module'; +import { ServiceApplicationsModule } from './service-applications/service-applications.module'; +import { UsersModule } from './users/users.module'; +import { ContentNotificationsModule } from './content-notifications/content-notifications.module'; +import { StatisticsModule } from './statistics/statistics.module'; +import { RequestMetricsService } from './common/request-metrics.service'; +import { RequestMetricsInterceptor } from './common/request-metrics.interceptor'; +import { SystemMetricsController } from './common/system-metrics.controller'; + +@Module({ + imports: [ + AppConfigModule, + SecurityModule, + DatabaseModule, + AuditModule, + PermissionsModule, + ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 100 }]), + AuthModule, + UsersModule, + ServiceApplicationsModule, + CaseManagementModule, + FollowUpsModule, + ParticipationModule, + ContentNotificationsModule, + StatisticsModule, + ], + controllers: [HealthController, SystemMetricsController], + providers: [ + HealthService, + RequestMetricsService, + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_GUARD, useClass: AuthenticationGuard }, + { provide: APP_GUARD, useClass: PermissionGuard }, + { provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }, + { provide: APP_INTERCEPTOR, useClass: RequestMetricsInterceptor }, + { provide: APP_FILTER, useClass: ApiExceptionFilter }, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestIdMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/audit/audit.module.ts b/apps/api/src/audit/audit.module.ts new file mode 100644 index 0000000..1180d10 --- /dev/null +++ b/apps/api/src/audit/audit.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { AuditService } from './audit.service'; + +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/api/src/audit/audit.service.spec.ts b/apps/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..67f463f --- /dev/null +++ b/apps/api/src/audit/audit.service.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import type { PrismaService } from '../database/prisma.service'; +import type { SensitiveDataService } from '../security/sensitive-data.service'; +import { AuditService } from './audit.service'; + +describe('AuditService metadata policy', () => { + const service = new AuditService({} as PrismaService, {} as SensitiveDataService); + + it('allows non-sensitive action metadata', () => { + expect(service.safeMetadata({ reasonCode: 'INVALID_CREDENTIALS', attempt: 2 })).toEqual({ + reasonCode: 'INVALID_CREDENTIALS', + attempt: 2, + }); + }); + + it('rejects sensitive metadata keys', () => { + expect(() => service.safeMetadata({ password: 'never-store-this' })).toThrow(/not allowed/); + expect(() => service.safeMetadata({ openid: 'never-store-this' })).toThrow(/not allowed/); + }); +}); diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts new file mode 100644 index 0000000..df2b44c --- /dev/null +++ b/apps/api/src/audit/audit.service.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { Prisma } from '../generated/prisma/client'; +import type { AuditResult } from '../generated/prisma/enums'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import { SensitiveDataService } from '../security/sensitive-data.service'; + +const SENSITIVE_METADATA_KEY = + /(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i; + +export interface AuditEvent { + action: string; + result: AuditResult; + actor?: AuthContext; + actorUserId?: bigint; + actorRole?: string; + resourceType?: string; + resourcePublicId?: string; + metadata?: Record; +} + +@Injectable() +export class AuditService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + ) {} + + async record( + request: RequestContext, + event: AuditEvent, + client: Prisma.TransactionClient | PrismaService = this.prisma, + ): Promise { + const metadata = this.safeMetadata(event.metadata); + const userAgent = + request + .header('user-agent') + ?.replace(/[\r\n\t]/g, ' ') + .slice(0, 255) || null; + await client.auditLog.create({ + data: { + requestId: request.requestId, + actorUserId: event.actor?.userId ?? event.actorUserId ?? null, + actorRole: event.actor?.roles[0] ?? event.actorRole ?? null, + action: event.action, + resourceType: event.resourceType ?? null, + resourcePublicId: event.resourcePublicId ?? null, + result: event.result, + metadataJson: metadata ?? undefined, + ipHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null, + userAgentSummary: userAgent, + }, + }); + } + + safeMetadata(metadata: AuditEvent['metadata']): Prisma.InputJsonObject | undefined { + if (!metadata) return undefined; + const entries = Object.entries(metadata).map(([key, value]) => { + if (SENSITIVE_METADATA_KEY.test(key)) { + throw new Error(`Sensitive audit metadata key is not allowed: ${key}`); + } + return [key, value] as const; + }); + return Object.fromEntries(entries); + } +} diff --git a/apps/api/src/auth/admin-auth.controller.ts b/apps/api/src/auth/admin-auth.controller.ts new file mode 100644 index 0000000..2e46302 --- /dev/null +++ b/apps/api/src/auth/admin-auth.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Inject, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Response } from 'express'; + +import { + PERMISSION_CODES, + type AdminLoginResponse, + type CurrentUserResponse, + type LogoutResponse, +} from '@lehuo/contracts'; + +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { Public } from '../common/decorators/public.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { AppConfigService } from '../config/app-config.service'; +import { SessionChannel } from '../generated/prisma/enums'; +import { AdminAuthService } from './admin-auth.service'; +import { AdminOriginGuard } from './admin-origin.guard'; +import { RequireSessionChannel } from './decorators/require-session-channel.decorator'; +import { AdminLoginDto } from './dto/admin-login.dto'; +import { ADMIN_SESSION_COOKIE } from './session.constants'; +import { SessionService } from './session.service'; + +@Controller('api/v1/admin/auth') +export class AdminAuthController { + constructor( + @Inject(AdminAuthService) private readonly adminAuth: AdminAuthService, + @Inject(SessionService) private readonly sessions: SessionService, + @Inject(AppConfigService) private readonly config: AppConfigService, + ) {} + + @Public() + @UseGuards(AdminOriginGuard) + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @Post('login') + async login( + @Body() input: AdminLoginDto, + @Req() request: RequestContext, + @Res({ passthrough: true }) response: Response, + ): Promise { + const result = await this.adminAuth.login(input, request); + response.cookie(ADMIN_SESSION_COOKIE, result.token, { + httpOnly: true, + secure: this.config.adminCookieSecure, + sameSite: 'strict', + path: '/api/v1', + maxAge: this.config.adminSessionTtlSeconds * 1_000, + }); + return result.response; + } + + @RequireSessionChannel(SessionChannel.ADMIN) + @RequirePermissions(PERMISSION_CODES.ADMIN_ACCESS) + @Get('me') + getCurrentAdmin(@CurrentAuth() auth: AuthContext): CurrentUserResponse { + return { user: this.sessions.toPublicUser(auth) }; + } + + @RequireSessionChannel(SessionChannel.ADMIN) + @UseGuards(AdminOriginGuard) + @Post('logout') + async logout( + @CurrentAuth() auth: AuthContext, + @Req() request: RequestContext, + @Res({ passthrough: true }) response: Response, + ): Promise { + await this.adminAuth.logout(auth, request); + response.clearCookie(ADMIN_SESSION_COOKIE, { + httpOnly: true, + secure: this.config.adminCookieSecure, + sameSite: 'strict', + path: '/api/v1', + }); + return { loggedOut: true }; + } +} diff --git a/apps/api/src/auth/admin-auth.service.ts b/apps/api/src/auth/admin-auth.service.ts new file mode 100644 index 0000000..eeeca59 --- /dev/null +++ b/apps/api/src/auth/admin-auth.service.ts @@ -0,0 +1,172 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; + +import { API_ERROR_CODES, type AdminLoginResponse } from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import { SessionChannel } from '../generated/prisma/enums'; +import type { AdminLoginDto } from './dto/admin-login.dto'; +import { PasswordService } from './password.service'; +import { SessionService } from './session.service'; + +const MAX_FAILED_LOGINS = 5; +const LOCK_DURATION_MS = 15 * 60 * 1_000; + +@Injectable() +export class AdminAuthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PasswordService) private readonly passwords: PasswordService, + @Inject(SessionService) private readonly sessions: SessionService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + async login( + input: AdminLoginDto, + request: RequestContext, + ): Promise<{ token: string; response: AdminLoginResponse }> { + const credential = await this.prisma.adminCredential.findUnique({ + where: { username: input.username }, + include: { user: true }, + }); + const passwordValid = await this.passwords.verifyOrDummy( + credential?.passwordHash ?? null, + input.password, + ); + if (!credential || !passwordValid) { + await this.recordFailedLogin( + credential?.id, + credential?.userId, + request, + 'INVALID_CREDENTIALS', + ); + throw new ApplicationError( + API_ERROR_CODES.INVALID_CREDENTIALS, + '用户名或密码不正确', + HttpStatus.UNAUTHORIZED, + ); + } + if (credential.lockedUntil && credential.lockedUntil.getTime() > Date.now()) { + await this.audit.record(request, { + action: 'AUTH_ADMIN_LOGIN', + result: 'FAILURE', + actorUserId: credential.userId, + resourceType: 'USER', + resourcePublicId: credential.user.publicId, + metadata: { reasonCode: 'ACCOUNT_LOCKED' }, + }); + throw new ApplicationError( + API_ERROR_CODES.ACCOUNT_LOCKED, + '账户暂时锁定,请稍后再试', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + if (credential.user.status !== 'ACTIVE') { + await this.audit.record(request, { + action: 'AUTH_ADMIN_LOGIN', + result: 'FAILURE', + actorUserId: credential.userId, + resourceType: 'USER', + resourcePublicId: credential.user.publicId, + metadata: { reasonCode: 'ACCOUNT_DISABLED' }, + }); + throw new ApplicationError( + API_ERROR_CODES.ACCOUNT_DISABLED, + '账户当前不可用,请联系管理员', + HttpStatus.FORBIDDEN, + ); + } + const session = await this.prisma.$transaction(async (transaction) => { + await transaction.adminCredential.update({ + where: { id: credential.id }, + data: { failedLoginCount: 0, lockedUntil: null }, + }); + await transaction.user.update({ + where: { id: credential.userId }, + data: { lastLoginAt: new Date() }, + }); + const created = await this.sessions.createRotatedSession( + transaction, + credential.userId, + SessionChannel.ADMIN, + ); + await this.audit.record( + request, + { + action: 'AUTH_ADMIN_LOGIN', + result: 'SUCCESS', + actorUserId: credential.userId, + resourceType: 'USER', + resourcePublicId: credential.user.publicId, + }, + transaction, + ); + return created; + }); + const auth = await this.sessions.authenticate(session.token); + return { + token: session.token, + response: { + user: this.sessions.toPublicUser(auth), + expiresAt: session.expiresAt.toISOString(), + }, + }; + } + + async logout(auth: AuthContext, request: RequestContext): Promise { + await this.prisma.$transaction(async (transaction) => { + await transaction.authSession.updateMany({ + where: { id: auth.sessionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await this.audit.record( + request, + { + action: 'AUTH_ADMIN_LOGOUT', + result: 'SUCCESS', + actor: auth, + resourceType: 'USER', + resourcePublicId: auth.userPublicId, + }, + transaction, + ); + }); + } + + private async recordFailedLogin( + credentialId: bigint | undefined, + userId: bigint | undefined, + request: RequestContext, + reasonCode: string, + ): Promise { + await this.prisma.$transaction(async (transaction) => { + if (credentialId) { + const current = await transaction.adminCredential.findUniqueOrThrow({ + where: { id: credentialId }, + }); + const failures = current.failedLoginCount + 1; + await transaction.adminCredential.update({ + where: { id: credentialId }, + data: { + failedLoginCount: failures, + lockedUntil: + failures >= MAX_FAILED_LOGINS ? new Date(Date.now() + LOCK_DURATION_MS) : null, + }, + }); + } + await this.audit.record( + request, + { + action: 'AUTH_ADMIN_LOGIN', + result: 'FAILURE', + ...(userId ? { actorUserId: userId } : {}), + resourceType: 'USER', + metadata: { reasonCode }, + }, + transaction, + ); + }); + } +} diff --git a/apps/api/src/auth/admin-origin.guard.ts b/apps/api/src/auth/admin-origin.guard.ts new file mode 100644 index 0000000..f1e6640 --- /dev/null +++ b/apps/api/src/auth/admin-origin.guard.ts @@ -0,0 +1,21 @@ +import { + ForbiddenException, + Inject, + Injectable, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; + +import type { RequestContext } from '../common/request-context'; +import { AppConfigService } from '../config/app-config.service'; + +@Injectable() +export class AdminOriginGuard implements CanActivate { + constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException(); + return true; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..166f958 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Get, Inject, Post, Req } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; + +import type { CurrentUserResponse, LogoutResponse, WechatLoginResponse } from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { Public } from '../common/decorators/public.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import { SessionChannel } from '../generated/prisma/enums'; +import { RequireSessionChannel } from './decorators/require-session-channel.decorator'; +import { WechatLoginDto } from './dto/wechat-login.dto'; +import { SessionService } from './session.service'; +import { WechatAuthService } from './wechat-auth.service'; + +@Controller('api/v1') +export class AuthController { + constructor( + @Inject(WechatAuthService) private readonly wechatAuth: WechatAuthService, + @Inject(SessionService) private readonly sessions: SessionService, + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + @Public() + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @Post('auth/wechat/login') + loginWithWechat( + @Body() input: WechatLoginDto, + @Req() request: RequestContext, + ): Promise { + return this.wechatAuth.login(input, request); + } + + @Get('me') + getCurrentUser(@CurrentAuth() auth: AuthContext): CurrentUserResponse { + return { user: this.sessions.toPublicUser(auth) }; + } + + @RequireSessionChannel(SessionChannel.WECHAT) + @Post('auth/logout') + async logout( + @CurrentAuth() auth: AuthContext, + @Req() request: RequestContext, + ): Promise { + await this.prisma.$transaction(async (transaction) => { + await transaction.authSession.updateMany({ + where: { id: auth.sessionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await this.audit.record( + request, + { + action: 'AUTH_WECHAT_LOGOUT', + result: 'SUCCESS', + actor: auth, + resourceType: 'USER', + resourcePublicId: auth.userPublicId, + }, + transaction, + ); + }); + return { loggedOut: true }; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..353ed12 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { AdminAuthController } from './admin-auth.controller'; +import { AdminAuthService } from './admin-auth.service'; +import { AdminOriginGuard } from './admin-origin.guard'; +import { AuthController } from './auth.controller'; +import { PasswordService } from './password.service'; +import { SessionService } from './session.service'; +import { WECHAT_AUTH_ADAPTER } from './wechat-auth.adapter'; +import { WechatAuthService } from './wechat-auth.service'; +import { WechatHttpAdapter } from './wechat-http.adapter'; + +@Module({ + imports: [AuditModule], + controllers: [AuthController, AdminAuthController], + providers: [ + SessionService, + PasswordService, + WechatAuthService, + AdminAuthService, + AdminOriginGuard, + WechatHttpAdapter, + { provide: WECHAT_AUTH_ADAPTER, useExisting: WechatHttpAdapter }, + ], + exports: [SessionService, PasswordService, AdminOriginGuard], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/authentication.guard.ts b/apps/api/src/auth/authentication.guard.ts new file mode 100644 index 0000000..61406e2 --- /dev/null +++ b/apps/api/src/auth/authentication.guard.ts @@ -0,0 +1,55 @@ +import { + Inject, + Injectable, + UnauthorizedException, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import type { SessionChannel } from '../generated/prisma/enums'; +import { IS_PUBLIC_KEY } from '../common/decorators/public.decorator'; +import type { RequestContext } from '../common/request-context'; +import { REQUIRED_SESSION_CHANNEL_KEY } from './decorators/require-session-channel.decorator'; +import { ADMIN_SESSION_COOKIE } from './session.constants'; +import { SessionService } from './session.service'; + +@Injectable() +export class AuthenticationGuard implements CanActivate { + constructor( + @Inject(Reflector) private readonly reflector: Reflector, + @Inject(SessionService) private readonly sessions: SessionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request) ?? this.extractAdminCookie(request); + if (!token) throw new UnauthorizedException(); + const auth = await this.sessions.authenticate(token); + const requiredChannel = this.reflector.getAllAndOverride( + REQUIRED_SESSION_CHANNEL_KEY, + [context.getHandler(), context.getClass()], + ); + if (requiredChannel && auth.channel !== requiredChannel) throw new UnauthorizedException(); + request.auth = auth; + return true; + } + + private extractBearerToken(request: RequestContext): string | null { + const authorization = request.header('authorization'); + if (!authorization) return null; + const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/.exec(authorization); + return match?.[1] ?? null; + } + + private extractAdminCookie(request: RequestContext): string | null { + const cookies = request.cookies as Record | undefined; + const value = cookies?.[ADMIN_SESSION_COOKIE]; + return typeof value === 'string' ? value : null; + } +} diff --git a/apps/api/src/auth/decorators/require-session-channel.decorator.ts b/apps/api/src/auth/decorators/require-session-channel.decorator.ts new file mode 100644 index 0000000..8283d1d --- /dev/null +++ b/apps/api/src/auth/decorators/require-session-channel.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; + +import type { SessionChannel } from '../../generated/prisma/enums'; + +export const REQUIRED_SESSION_CHANNEL_KEY = 'requiredSessionChannel'; +export const RequireSessionChannel = (channel: SessionChannel): MethodDecorator & ClassDecorator => + SetMetadata(REQUIRED_SESSION_CHANNEL_KEY, channel); diff --git a/apps/api/src/auth/dto/admin-login.dto.ts b/apps/api/src/auth/dto/admin-login.dto.ts new file mode 100644 index 0000000..53598cb --- /dev/null +++ b/apps/api/src/auth/dto/admin-login.dto.ts @@ -0,0 +1,18 @@ +import { Transform } from 'class-transformer'; +import { IsString, Length, Matches } from 'class-validator'; + +import type { AdminLoginRequest } from '@lehuo/contracts'; + +export class AdminLoginDto implements AdminLoginRequest { + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsString() + @Matches(/^[a-z0-9._-]+$/) + @Length(3, 64) + username!: string; + + @IsString() + @Length(12, 128) + password!: string; +} diff --git a/apps/api/src/auth/dto/wechat-login.dto.ts b/apps/api/src/auth/dto/wechat-login.dto.ts new file mode 100644 index 0000000..ce31d1f --- /dev/null +++ b/apps/api/src/auth/dto/wechat-login.dto.ts @@ -0,0 +1,9 @@ +import { IsString, Matches } from 'class-validator'; + +import type { WechatLoginRequest } from '@lehuo/contracts'; + +export class WechatLoginDto implements WechatLoginRequest { + @IsString() + @Matches(/^[A-Za-z0-9_-]{1,128}$/) + code!: string; +} diff --git a/apps/api/src/auth/password.service.spec.ts b/apps/api/src/auth/password.service.spec.ts new file mode 100644 index 0000000..fed410f --- /dev/null +++ b/apps/api/src/auth/password.service.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { PasswordService } from './password.service'; + +describe('PasswordService', () => { + it('hashes and verifies passwords with Argon2id', async () => { + const service = new PasswordService(); + const passwordHash = await service.hash('correct horse battery staple'); + + await expect(service.verifyOrDummy(passwordHash, 'correct horse battery staple')).resolves.toBe( + true, + ); + await expect(service.verifyOrDummy(passwordHash, 'incorrect password')).resolves.toBe(false); + }); + + it('performs a dummy verification for unknown accounts', async () => { + await expect( + new PasswordService().verifyOrDummy(null, 'unknown account password'), + ).resolves.toBe(false); + }); +}); diff --git a/apps/api/src/auth/password.service.ts b/apps/api/src/auth/password.service.ts new file mode 100644 index 0000000..8c6e598 --- /dev/null +++ b/apps/api/src/auth/password.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { hash, verify, argon2id } from 'argon2'; + +const ARGON_OPTIONS = { + type: argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1, +} as const; + +@Injectable() +export class PasswordService { + private readonly dummyHash = hash('not-a-real-account-password', ARGON_OPTIONS); + + hash(password: string): Promise { + return hash(password, ARGON_OPTIONS); + } + + async verifyOrDummy(passwordHash: string | null, password: string): Promise { + const selectedHash = passwordHash ?? (await this.dummyHash); + const valid = await verify(selectedHash, password); + return passwordHash !== null && valid; + } +} diff --git a/apps/api/src/auth/session.constants.ts b/apps/api/src/auth/session.constants.ts new file mode 100644 index 0000000..26964f6 --- /dev/null +++ b/apps/api/src/auth/session.constants.ts @@ -0,0 +1 @@ +export const ADMIN_SESSION_COOKIE = 'lehuo_admin_session'; diff --git a/apps/api/src/auth/session.service.ts b/apps/api/src/auth/session.service.ts new file mode 100644 index 0000000..24094e7 --- /dev/null +++ b/apps/api/src/auth/session.service.ts @@ -0,0 +1,150 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + PERMISSION_CODES, + ROLE_CODES, + USER_STATUSES, + USER_TYPES, + type AuthUser, + type PermissionCode, + type RoleCode, + type UserStatus, + type UserType, +} from '@lehuo/contracts'; + +import type { Prisma } from '../generated/prisma/client'; +import { SessionChannel } from '../generated/prisma/enums'; +import type { AuthContext } from '../common/request-context'; +import { AppConfigService } from '../config/app-config.service'; +import { PrismaService } from '../database/prisma.service'; +import { SensitiveDataService } from '../security/sensitive-data.service'; + +const PERMISSIONS = Object.values(PERMISSION_CODES) as string[]; + +function isRoleCode(value: string): value is RoleCode { + return (ROLE_CODES as readonly string[]).includes(value); +} + +function isPermissionCode(value: string): value is PermissionCode { + return PERMISSIONS.includes(value); +} + +function isUserStatus(value: string): value is UserStatus { + return (USER_STATUSES as readonly string[]).includes(value); +} + +function isUserType(value: string): value is UserType { + return (USER_TYPES as readonly string[]).includes(value); +} + +@Injectable() +export class SessionService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppConfigService) private readonly config: AppConfigService, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + ) {} + + async createRotatedSession( + client: Prisma.TransactionClient, + userId: bigint, + channel: SessionChannel, + ): Promise<{ token: string; expiresAt: Date }> { + const token = randomBytes(32).toString('base64url'); + const ttlSeconds = + channel === SessionChannel.ADMIN + ? this.config.adminSessionTtlSeconds + : this.config.wechatSessionTtlSeconds; + const expiresAt = new Date(Date.now() + ttlSeconds * 1_000); + await client.authSession.updateMany({ + where: { userId, channel, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await client.authSession.create({ + data: { + publicId: randomUUID(), + userId, + channel, + tokenHash: this.sensitiveData.sessionTokenHash(token), + expiresAt, + }, + }); + return { token, expiresAt }; + } + + async authenticate(token: string): Promise { + const session = await this.prisma.authSession.findUnique({ + where: { tokenHash: this.sensitiveData.sessionTokenHash(token) }, + include: { + user: { + include: { + staffProfile: true, + userRoles: { + include: { + role: { + include: { + rolePermissions: { include: { permission: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + if ( + !session || + session.revokedAt || + session.expiresAt.getTime() <= Date.now() || + session.user.status !== 'ACTIVE' + ) { + throw new UnauthorizedException(); + } + const roles = session.user.userRoles.map(({ role }) => role.code).filter(isRoleCode); + const permissions = [ + ...new Set( + session.user.userRoles.flatMap(({ role }) => + role.rolePermissions.map(({ permission }) => permission.code), + ), + ), + ].filter(isPermissionCode); + if (!isUserStatus(session.user.status) || !isUserType(session.user.userType)) { + throw new UnauthorizedException(); + } + await this.prisma.authSession.update({ + where: { id: session.id }, + data: { lastUsedAt: new Date() }, + }); + return { + userId: session.user.id, + userPublicId: session.user.publicId, + status: session.user.status, + userType: session.user.userType, + displayName: session.user.staffProfile?.displayName ?? session.user.nickname, + roles, + permissions, + sessionId: session.id, + channel: session.channel, + rawToken: token, + }; + } + + async revoke(auth: AuthContext): Promise { + await this.prisma.authSession.updateMany({ + where: { id: auth.sessionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + toPublicUser(auth: AuthContext): AuthUser { + return { + publicId: auth.userPublicId, + status: auth.status, + userType: auth.userType, + displayName: auth.displayName, + roles: auth.roles, + permissions: auth.permissions, + }; + } +} diff --git a/apps/api/src/auth/wechat-auth.adapter.ts b/apps/api/src/auth/wechat-auth.adapter.ts new file mode 100644 index 0000000..1c33bcf --- /dev/null +++ b/apps/api/src/auth/wechat-auth.adapter.ts @@ -0,0 +1,10 @@ +export const WECHAT_AUTH_ADAPTER = Symbol('WECHAT_AUTH_ADAPTER'); + +export interface WechatIdentityResult { + openid: string; + unionid?: string; +} + +export interface WechatAuthAdapter { + exchangeCode(code: string): Promise; +} diff --git a/apps/api/src/auth/wechat-auth.service.ts b/apps/api/src/auth/wechat-auth.service.ts new file mode 100644 index 0000000..4277d95 --- /dev/null +++ b/apps/api/src/auth/wechat-auth.service.ts @@ -0,0 +1,126 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import { API_ERROR_CODES, type WechatLoginResponse } from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { RequestContext } from '../common/request-context'; +import { AppConfigService } from '../config/app-config.service'; +import { PrismaService } from '../database/prisma.service'; +import { SessionChannel } from '../generated/prisma/enums'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { WechatLoginDto } from './dto/wechat-login.dto'; +import { SessionService } from './session.service'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from './wechat-auth.adapter'; + +@Injectable() +export class WechatAuthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppConfigService) private readonly config: AppConfigService, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + @Inject(SessionService) private readonly sessions: SessionService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(WECHAT_AUTH_ADAPTER) private readonly adapter: WechatAuthAdapter, + ) {} + + async login(input: WechatLoginDto, request: RequestContext): Promise { + let identityResult; + try { + identityResult = await this.adapter.exchangeCode(input.code); + } catch (error) { + await this.audit.record(request, { + action: 'AUTH_WECHAT_LOGIN', + result: 'FAILURE', + resourceType: 'USER', + metadata: { reasonCode: 'WECHAT_EXCHANGE_FAILED' }, + }); + throw error; + } + if (!this.config.wechatAppId) { + throw new ApplicationError( + API_ERROR_CODES.WECHAT_LOGIN_FAILED, + '微信登录暂时不可用,请稍后重试', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + const openidHash = this.sensitiveData.lookupHash('wechat-openid', identityResult.openid); + const unionidHash = identityResult.unionid + ? this.sensitiveData.lookupHash('wechat-unionid', identityResult.unionid) + : null; + const result = await this.prisma.$transaction(async (transaction) => { + let identity = await transaction.wechatIdentity.findFirst({ + where: { + OR: [ + { appid: this.config.wechatAppId!, openidHash }, + ...(unionidHash ? [{ unionidHash }] : []), + ], + }, + include: { user: true }, + }); + if (!identity) { + const user = await transaction.user.create({ + data: { publicId: randomUUID(), userType: 'PUBLIC' }, + }); + identity = await transaction.wechatIdentity.create({ + data: { + userId: user.id, + appid: this.config.wechatAppId!, + openidHash, + openidCiphertext: this.sensitiveData.encrypt(identityResult.openid), + unionidHash, + unionidCiphertext: identityResult.unionid + ? this.sensitiveData.encrypt(identityResult.unionid) + : null, + }, + include: { user: true }, + }); + } else if (identityResult.unionid && !identity.unionidHash) { + identity = await transaction.wechatIdentity.update({ + where: { id: identity.id }, + data: { + unionidHash, + unionidCiphertext: this.sensitiveData.encrypt(identityResult.unionid), + }, + include: { user: true }, + }); + } + if (identity.user.status !== 'ACTIVE') { + throw new ApplicationError( + API_ERROR_CODES.ACCOUNT_DISABLED, + '账户当前不可用,请联系客服', + HttpStatus.FORBIDDEN, + ); + } + await transaction.user.update({ + where: { id: identity.userId }, + data: { lastLoginAt: new Date() }, + }); + const session = await this.sessions.createRotatedSession( + transaction, + identity.userId, + SessionChannel.WECHAT, + ); + await this.audit.record( + request, + { + action: 'AUTH_WECHAT_LOGIN', + result: 'SUCCESS', + actorUserId: identity.userId, + resourceType: 'USER', + resourcePublicId: identity.user.publicId, + }, + transaction, + ); + return session; + }); + const auth = await this.sessions.authenticate(result.token); + return { + accessToken: result.token, + tokenType: 'Bearer', + expiresAt: result.expiresAt.toISOString(), + user: this.sessions.toPublicUser(auth), + }; + } +} diff --git a/apps/api/src/auth/wechat-http.adapter.ts b/apps/api/src/auth/wechat-http.adapter.ts new file mode 100644 index 0000000..5bf98e6 --- /dev/null +++ b/apps/api/src/auth/wechat-http.adapter.ts @@ -0,0 +1,51 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; + +import { API_ERROR_CODES } from '@lehuo/contracts'; + +import { ApplicationError } from '../common/application-error'; +import { AppConfigService } from '../config/app-config.service'; +import type { WechatAuthAdapter, WechatIdentityResult } from './wechat-auth.adapter'; + +interface WechatCodeResponse { + openid?: string; + unionid?: string; + errcode?: number; +} + +@Injectable() +export class WechatHttpAdapter implements WechatAuthAdapter { + constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {} + + async exchangeCode(code: string): Promise { + if (!this.config.wechatAppId || !this.config.wechatAppSecret) { + throw new ApplicationError( + API_ERROR_CODES.WECHAT_LOGIN_FAILED, + '微信登录暂时不可用,请稍后重试', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + const endpoint = new URL('https://api.weixin.qq.com/sns/jscode2session'); + endpoint.search = new URLSearchParams({ + appid: this.config.wechatAppId, + secret: this.config.wechatAppSecret, + js_code: code, + grant_type: 'authorization_code', + }).toString(); + try { + const response = await fetch(endpoint, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error('wechat-http-status'); + const payload = (await response.json()) as WechatCodeResponse; + if (payload.errcode || !payload.openid) throw new Error('wechat-code-rejected'); + return { + openid: payload.openid, + ...(payload.unionid ? { unionid: payload.unionid } : {}), + }; + } catch { + throw new ApplicationError( + API_ERROR_CODES.WECHAT_LOGIN_FAILED, + '微信登录失败,请稍后重试', + HttpStatus.BAD_GATEWAY, + ); + } + } +} diff --git a/apps/api/src/case-management/admin-application-workflow.controller.ts b/apps/api/src/case-management/admin-application-workflow.controller.ts new file mode 100644 index 0000000..4e1a7b8 --- /dev/null +++ b/apps/api/src/case-management/admin-application-workflow.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common'; + +import { PERMISSION_CODES, type AcceptServiceApplicationResponse } from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { CaseManagementService } from './case-management.service'; +import { + AcceptServiceApplicationDto, + TransitionServiceApplicationDto, +} from './dto/case-management.dto'; + +@Controller('api/v1/admin/service-applications') +@RequireSessionChannel(SessionChannel.ADMIN) +@RequirePermissions(PERMISSION_CODES.APPLICATIONS_MANAGE) +export class AdminApplicationWorkflowController { + constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {} + + @Post(':publicId/transition') + transition( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: TransitionServiceApplicationDto, + }), + ) + input: TransitionServiceApplicationDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise<{ status: string }> { + return this.cases.transitionApplication(publicId, input, actor, request); + } + + @Post(':publicId/accept') + accept( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: AcceptServiceApplicationDto, + }), + ) + input: AcceptServiceApplicationDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.acceptApplication(publicId, input, actor, request); + } +} diff --git a/apps/api/src/case-management/admin-beneficiaries.controller.ts b/apps/api/src/case-management/admin-beneficiaries.controller.ts new file mode 100644 index 0000000..01aff5e --- /dev/null +++ b/apps/api/src/case-management/admin-beneficiaries.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Put, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import { + PERMISSION_CODES, + type BeneficiaryDetailResponse, + type BeneficiaryTagOption, + type PaginatedBeneficiariesResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { CaseManagementService } from './case-management.service'; +import { BeneficiaryQueryDto, UpdateBeneficiaryDto } from './dto/case-management.dto'; + +@Controller('api/v1/admin/beneficiaries') +@RequireSessionChannel(SessionChannel.ADMIN) +@RequirePermissions(PERMISSION_CODES.BENEFICIARIES_READ) +export class AdminBeneficiariesController { + constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {} + + @Get('tags') + tags(): Promise { + return this.cases.tags(); + } + + @Get() + list( + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: BeneficiaryQueryDto, + }), + ) + query: BeneficiaryQueryDto, + ): Promise { + return this.cases.listBeneficiaries(query); + } + + @Get(':publicId') + get( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.getBeneficiary(publicId, actor, request); + } + + @Put(':publicId') + @RequirePermissions(PERMISSION_CODES.BENEFICIARIES_MANAGE) + update( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: UpdateBeneficiaryDto, + }), + ) + input: UpdateBeneficiaryDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.updateBeneficiary(publicId, input, actor, request); + } +} diff --git a/apps/api/src/case-management/admin-service-cases.controller.ts b/apps/api/src/case-management/admin-service-cases.controller.ts new file mode 100644 index 0000000..f2c7306 --- /dev/null +++ b/apps/api/src/case-management/admin-service-cases.controller.ts @@ -0,0 +1,140 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Put, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import { + PERMISSION_CODES, + type AssigneeOption, + type PaginatedServiceCasesResponse, + type ServiceCaseDetailResponse, + type ServiceRecordResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { CaseManagementService } from './case-management.service'; +import { + CreateServiceRecordDto, + ReplaceCaseAssigneesDto, + ServiceCaseQueryDto, + UpdateServiceRecordDto, +} from './dto/case-management.dto'; + +@Controller('api/v1/admin/service-cases') +@RequireSessionChannel(SessionChannel.ADMIN) +@RequirePermissions(PERMISSION_CODES.CASES_READ) +export class AdminServiceCasesController { + constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {} + + @Get('assignee-options') + @RequirePermissions(PERMISSION_CODES.CASES_ASSIGN) + assignees(): Promise { + return this.cases.assigneeOptions(); + } + + @Get() + list( + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: ServiceCaseQueryDto, + }), + ) + query: ServiceCaseQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.cases.listCases(query, actor); + } + + @Get(':publicId') + get( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.getCase(publicId, actor, request); + } + + @Post(':publicId/start') + @RequirePermissions(PERMISSION_CODES.CASES_MANAGE) + start( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.startCase(publicId, actor, request); + } + + @Put(':publicId/assignees') + @RequirePermissions(PERMISSION_CODES.CASES_ASSIGN) + replaceAssignees( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: ReplaceCaseAssigneesDto, + }), + ) + input: ReplaceCaseAssigneesDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.replaceAssignees(publicId, input, actor, request); + } + + @Post(':publicId/records') + @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE) + createRecord( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: CreateServiceRecordDto, + }), + ) + input: CreateServiceRecordDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.createRecord(publicId, input, actor, request); + } + + @Patch(':publicId/records/:recordPublicId') + @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE) + updateRecord( + @Param('publicId') publicId: string, + @Param('recordPublicId') recordPublicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: UpdateServiceRecordDto, + }), + ) + input: UpdateServiceRecordDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.updateRecord(publicId, recordPublicId, input, actor, request); + } +} diff --git a/apps/api/src/case-management/assigned-service-cases.controller.ts b/apps/api/src/case-management/assigned-service-cases.controller.ts new file mode 100644 index 0000000..7995d06 --- /dev/null +++ b/apps/api/src/case-management/assigned-service-cases.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import { + PERMISSION_CODES, + type PaginatedServiceCasesResponse, + type ServiceCaseDetailResponse, + type ServiceRecordResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { CaseManagementService } from './case-management.service'; +import { + CreateServiceRecordDto, + ServiceCaseQueryDto, + UpdateServiceRecordDto, +} from './dto/case-management.dto'; + +@Controller('api/v1/service-cases') +@RequireSessionChannel(SessionChannel.WECHAT) +@RequirePermissions(PERMISSION_CODES.ASSIGNED_CASES_READ) +export class AssignedServiceCasesController { + constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {} + + @Get() + list( + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: ServiceCaseQueryDto, + }), + ) + query: ServiceCaseQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.cases.listCases(query, actor); + } + + @Get(':publicId') + get( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.getCase(publicId, actor, request, true); + } + + @Post(':publicId/records') + @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE) + createRecord( + @Param('publicId') publicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: CreateServiceRecordDto, + }), + ) + input: CreateServiceRecordDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.createRecord(publicId, input, actor, request); + } + + @Patch(':publicId/records/:recordPublicId') + @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE) + updateRecord( + @Param('publicId') publicId: string, + @Param('recordPublicId') recordPublicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: UpdateServiceRecordDto, + }), + ) + input: UpdateServiceRecordDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.cases.updateRecord(publicId, recordPublicId, input, actor, request); + } +} diff --git a/apps/api/src/case-management/case-files.controller.ts b/apps/api/src/case-management/case-files.controller.ts new file mode 100644 index 0000000..a0cfba3 --- /dev/null +++ b/apps/api/src/case-management/case-files.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common'; + +import { + PERMISSION_CODES, + type ConfirmUploadResponse, + type CreateUploadIntentResponse, + type DownloadUrlResponse, +} from '@lehuo/contracts'; + +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { FilesService } from '../files/files.service'; +import { CreateUploadIntentDto } from './dto/case-management.dto'; + +@Controller('api/v1') +@RequirePermissions(PERMISSION_CODES.FILES_WRITE) +export class CaseFilesController { + constructor(@Inject(FilesService) private readonly files: FilesService) {} + + @Post('service-cases/:casePublicId/files/upload-intents') + createIntent( + @Param('casePublicId') casePublicId: string, + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: CreateUploadIntentDto, + }), + ) + input: CreateUploadIntentDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.files.createUploadIntent(casePublicId, input, actor, request); + } + + @Post('files/:publicId/confirm') + confirm( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.files.confirm(publicId, actor, request); + } + + @Get('files/:publicId/download-url') + download( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.files.downloadUrl(publicId, actor, request); + } +} diff --git a/apps/api/src/case-management/case-management.module.ts b/apps/api/src/case-management/case-management.module.ts new file mode 100644 index 0000000..d238a21 --- /dev/null +++ b/apps/api/src/case-management/case-management.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { FilesModule } from '../files/files.module'; +import { FilesService } from '../files/files.service'; +import { ContentNotificationsModule } from '../content-notifications/content-notifications.module'; +import { AdminApplicationWorkflowController } from './admin-application-workflow.controller'; +import { AdminBeneficiariesController } from './admin-beneficiaries.controller'; +import { AdminServiceCasesController } from './admin-service-cases.controller'; +import { AssignedServiceCasesController } from './assigned-service-cases.controller'; +import { CaseFilesController } from './case-files.controller'; +import { CaseManagementRepository } from './case-management.repository'; +import { CaseManagementService } from './case-management.service'; + +@Module({ + imports: [AuditModule, FilesModule, ContentNotificationsModule], + controllers: [ + AdminApplicationWorkflowController, + AdminBeneficiariesController, + AdminServiceCasesController, + AssignedServiceCasesController, + CaseFilesController, + ], + providers: [CaseManagementRepository, CaseManagementService, FilesService], +}) +export class CaseManagementModule {} diff --git a/apps/api/src/case-management/case-management.repository.ts b/apps/api/src/case-management/case-management.repository.ts new file mode 100644 index 0000000..54db322 --- /dev/null +++ b/apps/api/src/case-management/case-management.repository.ts @@ -0,0 +1,856 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { + BeneficiaryConfirmStatus, + BeneficiaryGender, + BeneficiaryStatus, + ServiceCasePriority, + ServiceCaseStatus, + ServiceCategory, + ServiceRecordType, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { NotificationsService } from '../content-notifications/notifications.service'; + +const beneficiaryInclude = { + contacts: { where: { removedAt: null }, orderBy: { createdAt: 'asc' as const } }, + beneficiaryTags: { include: { tag: true }, orderBy: { assignedAt: 'asc' as const } }, + _count: { select: { serviceCases: true } }, +} satisfies Prisma.BeneficiaryProfileInclude; + +const caseInclude = { + application: true, + beneficiary: { include: beneficiaryInclude }, + owner: { include: { staffProfile: true } }, + assignees: { + where: { removedAt: null }, + include: { user: { include: { staffProfile: true } } }, + orderBy: [{ role: 'asc' as const }, { assignedAt: 'asc' as const }], + }, + statusHistory: { orderBy: { createdAt: 'asc' as const } }, + serviceRecords: { + include: { + creator: { include: { staffProfile: true } }, + attachments: { include: { file: true } }, + }, + orderBy: [{ serviceStartAt: 'desc' as const }, { id: 'desc' as const }], + }, +} satisfies Prisma.ServiceCaseInclude; + +export type StoredBeneficiary = Prisma.BeneficiaryProfileGetPayload<{ + include: typeof beneficiaryInclude; +}>; +export type StoredServiceCase = Prisma.ServiceCaseGetPayload<{ include: typeof caseInclude }>; + +export interface PreparedContact { + publicId: string; + nameEncrypted: string; + relation: string; + mobileEncrypted: string; + mobileHash: string; + isGuardian: boolean; + isEmergencyContact: boolean; +} + +export interface PreparedBeneficiary { + publicId: string; + displayNameEncrypted: string; + displayNameHash: string; + gender?: BeneficiaryGender; + birthYear?: number; + mobileEncrypted?: string; + mobileHash?: string; + regionCode: string; + addressDetailEncrypted?: string; + primaryCategory: ServiceCategory; + keyCareFlag: boolean; + isMinor: boolean; + notesEncrypted?: string; + contacts: PreparedContact[]; + tagCodes: string[]; + guardianConsentConfirmed: boolean; +} + +export interface AcceptApplicationInput { + applicationPublicId: string; + acceptRequestId: string; + acceptRequestFingerprint: string; + existingBeneficiaryId?: bigint; + newBeneficiary?: PreparedBeneficiary; + ownerStaffId: bigint; + collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>; + casePublicId: string; + caseNo: string; + priority: ServiceCasePriority; + plannedStartAt?: Date; + plannedEndAt?: Date; + serviceLocationEncrypted?: string; + summaryEncrypted: string; +} + +export interface BeneficiaryFilters { + primaryCategory?: ServiceCategory; + status?: BeneficiaryStatus; + keyCareFlag?: boolean; +} + +export interface CaseFilters { + status?: ServiceCaseStatus; + category?: ServiceCategory; + caseNo?: string; +} + +export interface PreparedServiceRecord { + publicId: string; + clientRequestId: string; + requestFingerprint: string; + serviceStartAt: Date; + serviceEndAt?: Date; + serviceType: ServiceRecordType; + contentEncrypted: string; + resultEncrypted: string; + followUpSuggestionEncrypted?: string; + beneficiaryConfirmStatus: BeneficiaryConfirmStatus; + attachmentPublicIds: string[]; +} + +export interface PreparedBeneficiaryUpdate extends PreparedBeneficiary { + status: BeneficiaryStatus; +} + +@Injectable() +export class CaseManagementRepository { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(NotificationsService) private readonly notifications: NotificationsService, + ) {} + + findApplication(publicId: string) { + return this.prisma.serviceApplication.findUnique({ + where: { publicId }, + include: { serviceCase: { include: caseInclude } }, + }); + } + + findBeneficiary(publicId: string): Promise { + return this.prisma.beneficiaryProfile.findUnique({ + where: { publicId }, + include: beneficiaryInclude, + }); + } + + async listBeneficiaries(filters: BeneficiaryFilters, page: number, pageSize: number) { + const where: Prisma.BeneficiaryProfileWhereInput = { + ...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}), + ...(filters.status ? { status: filters.status } : {}), + ...(filters.keyCareFlag === undefined ? {} : { keyCareFlag: filters.keyCareFlag }), + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.beneficiaryProfile.findMany({ + where, + include: beneficiaryInclude, + orderBy: [{ keyCareFlag: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.beneficiaryProfile.count({ where }), + ]); + return { items, total }; + } + + async updateBeneficiary( + existing: StoredBeneficiary, + input: PreparedBeneficiaryUpdate, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const tags = await transaction.tag.findMany({ where: { code: { in: input.tagCodes } } }); + if (tags.length !== new Set(input.tagCodes).size) throw new Error('Unknown beneficiary tag'); + await transaction.beneficiaryProfile.update({ + where: { id: existing.id }, + data: { + displayNameEncrypted: input.displayNameEncrypted, + displayNameHash: input.displayNameHash, + gender: input.gender, + birthYear: input.birthYear, + mobileEncrypted: input.mobileEncrypted, + mobileHash: input.mobileHash, + regionCode: input.regionCode, + addressDetailEncrypted: input.addressDetailEncrypted, + keyCareFlag: input.keyCareFlag, + isMinor: input.isMinor, + status: input.status, + notesEncrypted: input.notesEncrypted, + updatedById: actor.userId, + }, + }); + await transaction.beneficiaryContact.updateMany({ + where: { beneficiaryId: existing.id, removedAt: null }, + data: { removedAt: new Date() }, + }); + for (const contact of input.contacts) { + let consentRecordId: bigint | undefined; + if (input.isMinor && contact.isGuardian && input.guardianConsentConfirmed) { + const consent = await transaction.consent.create({ + data: { + publicId: crypto.randomUUID(), + beneficiaryId: existing.id, + consentType: 'BENEFICIARY_GUARDIAN', + version: 'beneficiary-guardian-v1', + grantedBy: 'GUARDIAN', + grantedAt: new Date(), + evidenceJson: { source: 'ADMIN_STAFF_RECORDED', operation: 'PROFILE_UPDATE' }, + }, + }); + consentRecordId = consent.id; + } + await transaction.beneficiaryContact.create({ + data: { ...contact, beneficiaryId: existing.id, consentRecordId }, + }); + } + await transaction.beneficiaryTag.deleteMany({ where: { beneficiaryId: existing.id } }); + if (tags.length) { + await transaction.beneficiaryTag.createMany({ + data: tags.map((tag) => ({ + beneficiaryId: existing.id, + tagId: tag.id, + assignedById: actor.userId, + })), + }); + } + await this.audit.record( + request, + { + action: 'BENEFICIARY_PROFILE_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'BENEFICIARY', + resourcePublicId: existing.publicId, + metadata: { + status: input.status, + keyCareFlag: input.keyCareFlag, + isMinor: input.isMinor, + }, + }, + transaction, + ); + return transaction.beneficiaryProfile.findUniqueOrThrow({ + where: { id: existing.id }, + include: beneficiaryInclude, + }); + }); + } + + listTags() { + return this.prisma.tag.findMany({ orderBy: { code: 'asc' } }); + } + + findOwnerCandidate(publicId: string) { + return this.prisma.user.findFirst({ + where: { publicId, status: 'ACTIVE', staffProfile: { status: 'ACTIVE' } }, + include: { staffProfile: true }, + }); + } + + async findCollaboratorCandidates(publicIds: string[]) { + return this.prisma.user.findMany({ + where: { + publicId: { in: publicIds }, + status: 'ACTIVE', + OR: [ + { staffProfile: { status: 'ACTIVE' } }, + { userRoles: { some: { role: { code: 'VOLUNTEER' } } } }, + ], + }, + include: { staffProfile: true, userRoles: { include: { role: true } } }, + }); + } + + async listAssigneeOptions() { + return this.prisma.user.findMany({ + where: { + status: 'ACTIVE', + OR: [ + { staffProfile: { status: 'ACTIVE' } }, + { userRoles: { some: { role: { code: 'VOLUNTEER' } } } }, + ], + }, + include: { staffProfile: true, userRoles: { include: { role: true } } }, + orderBy: [{ createdAt: 'asc' }], + take: 200, + }); + } + + async transitionApplication( + publicId: string, + expectedStatus: 'SUBMITTED' | 'PENDING_CONTACT', + targetStatus: 'PENDING_CONTACT' | 'ASSESSING', + reasonCode: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const application = await transaction.serviceApplication.findUnique({ where: { publicId } }); + if (!application) return false; + const updated = await transaction.serviceApplication.updateMany({ + where: { id: application.id, status: expectedStatus }, + data: { status: targetStatus }, + }); + if (updated.count !== 1) return false; + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: expectedStatus, + toStatus: targetStatus, + operatorUserId: actor.userId, + reasonCode, + }, + }); + if (targetStatus === 'PENDING_CONTACT') + await this.notifications.create(transaction, { + userId: application.applicantUserId, + eventKey: `application:${publicId}:accepted`, + type: 'APPLICATION_ACCEPTED', + title: '服务申请已受理', + body: '工作人员将与您联系并进一步了解需求。', + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: publicId, + }); + await this.audit.record( + request, + { + action: 'SERVICE_APPLICATION_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: publicId, + metadata: { fromStatus: expectedStatus, toStatus: targetStatus, reasonCode }, + }, + transaction, + ); + return true; + }); + } + + async acceptApplication( + input: AcceptApplicationInput, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const application = await transaction.serviceApplication.findUniqueOrThrow({ + where: { publicId: input.applicationPublicId }, + }); + const owner = await transaction.user.findFirstOrThrow({ + where: { + id: input.ownerStaffId, + status: 'ACTIVE', + staffProfile: { status: 'ACTIVE' }, + }, + }); + const collaboratorIds = input.collaborators.map((collaborator) => collaborator.userId); + const activeCollaboratorCount = await transaction.user.count({ + where: { + id: { in: collaboratorIds }, + status: 'ACTIVE', + OR: [ + { staffProfile: { status: 'ACTIVE' } }, + { userRoles: { some: { role: { code: 'VOLUNTEER' } } } }, + ], + }, + }); + if (activeCollaboratorCount !== new Set(collaboratorIds).size) { + throw new Error('ASSIGNEE_STATE_CONFLICT'); + } + let beneficiaryId = input.existingBeneficiaryId; + if (!beneficiaryId && input.newBeneficiary) { + const prepared = input.newBeneficiary; + const tags = await transaction.tag.findMany({ where: { code: { in: prepared.tagCodes } } }); + if (tags.length !== new Set(prepared.tagCodes).size) + throw new Error('Unknown beneficiary tag'); + const beneficiary = await transaction.beneficiaryProfile.create({ + data: { + publicId: prepared.publicId, + displayNameEncrypted: prepared.displayNameEncrypted, + displayNameHash: prepared.displayNameHash, + gender: prepared.gender, + birthYear: prepared.birthYear, + mobileEncrypted: prepared.mobileEncrypted, + mobileHash: prepared.mobileHash, + regionCode: prepared.regionCode, + addressDetailEncrypted: prepared.addressDetailEncrypted, + primaryCategory: prepared.primaryCategory, + keyCareFlag: prepared.keyCareFlag, + isMinor: prepared.isMinor, + notesEncrypted: prepared.notesEncrypted, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + beneficiaryId = beneficiary.id; + for (const contact of prepared.contacts) { + let consentRecordId: bigint | undefined; + if (prepared.isMinor && contact.isGuardian && prepared.guardianConsentConfirmed) { + const consent = await transaction.consent.create({ + data: { + publicId: crypto.randomUUID(), + beneficiaryId, + consentType: 'BENEFICIARY_GUARDIAN', + version: 'beneficiary-guardian-v1', + grantedBy: 'GUARDIAN', + grantedAt: new Date(), + evidenceJson: { + source: 'ADMIN_STAFF_RECORDED', + relatedApplicationPublicId: input.applicationPublicId, + }, + }, + }); + consentRecordId = consent.id; + } + await transaction.beneficiaryContact.create({ + data: { ...contact, beneficiaryId, consentRecordId }, + }); + } + if (tags.length) { + await transaction.beneficiaryTag.createMany({ + data: tags.map((tag) => ({ + beneficiaryId: beneficiary.id, + tagId: tag.id, + assignedById: actor.userId, + })), + }); + } + } + if (!beneficiaryId) throw new Error('Beneficiary resolution failed'); + const accepted = await transaction.serviceApplication.updateMany({ + where: { id: application.id, status: 'ASSESSING' }, + data: { status: 'ACCEPTED', beneficiaryId, acceptedAt: new Date() }, + }); + if (accepted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT'); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: 'ASSESSING', + toStatus: 'ACCEPTED', + operatorUserId: actor.userId, + reasonCode: 'ASSESSMENT_ACCEPTED', + }, + }); + const serviceCase = await transaction.serviceCase.create({ + data: { + publicId: input.casePublicId, + caseNo: input.caseNo, + applicationId: application.id, + acceptRequestId: input.acceptRequestId, + acceptRequestFingerprint: input.acceptRequestFingerprint, + beneficiaryId, + ownerStaffId: owner.id, + category: application.serviceCategory, + priority: input.priority, + plannedStartAt: input.plannedStartAt, + plannedEndAt: input.plannedEndAt, + serviceLocationEncrypted: input.serviceLocationEncrypted, + summaryEncrypted: input.summaryEncrypted, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await transaction.caseAssignee.create({ + data: { + caseId: serviceCase.id, + userId: owner.id, + assigneeType: 'STAFF', + role: 'OWNER', + assignedById: actor.userId, + }, + }); + for (const collaborator of input.collaborators) { + if (collaborator.userId === owner.id) continue; + await transaction.caseAssignee.create({ + data: { + caseId: serviceCase.id, + userId: collaborator.userId, + assigneeType: collaborator.type, + role: 'COLLABORATOR', + assignedById: actor.userId, + }, + }); + } + await transaction.serviceCaseStatusHistory.create({ + data: { + caseId: serviceCase.id, + fromStatus: null, + toStatus: 'ASSIGNED', + operatorUserId: actor.userId, + reasonCode: 'CASE_CREATED_AND_ASSIGNED', + }, + }); + await transaction.serviceApplication.update({ + where: { id: application.id }, + data: { status: 'SERVICE_PLANNED' }, + }); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: 'ACCEPTED', + toStatus: 'SERVICE_PLANNED', + operatorUserId: actor.userId, + reasonCode: 'CASE_CREATED_AND_ASSIGNED', + }, + }); + await this.audit.record( + request, + { + action: 'SERVICE_APPLICATION_ACCEPTED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: input.applicationPublicId, + metadata: { + casePublicId: input.casePublicId, + beneficiaryPublicId: input.newBeneficiary?.publicId ?? null, + }, + }, + transaction, + ); + await this.audit.record( + request, + { + action: 'SERVICE_CASE_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_CASE', + resourcePublicId: input.casePublicId, + metadata: { priority: input.priority }, + }, + transaction, + ); + return transaction.serviceCase.findUniqueOrThrow({ + where: { id: serviceCase.id }, + include: caseInclude, + }); + }); + } + + findCase(publicId: string): Promise { + return this.prisma.serviceCase.findUnique({ where: { publicId }, include: caseInclude }); + } + + findCaseById(id: bigint): Promise { + return this.prisma.serviceCase.findUnique({ where: { id }, include: caseInclude }); + } + + async listCases( + filters: CaseFilters, + actorUserId: bigint, + accessAll: boolean, + page: number, + pageSize: number, + ) { + const where: Prisma.ServiceCaseWhereInput = { + ...(accessAll ? {} : { assignees: { some: { userId: actorUserId, removedAt: null } } }), + ...(filters.status ? { status: filters.status } : {}), + ...(filters.category ? { category: filters.category } : {}), + ...(filters.caseNo ? { caseNo: { startsWith: filters.caseNo } } : {}), + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.serviceCase.findMany({ + where, + include: caseInclude, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.serviceCase.count({ where }), + ]); + return { items, total }; + } + + isAssigned(caseId: bigint, userId: bigint) { + return this.prisma.caseAssignee.count({ where: { caseId, userId, removedAt: null } }); + } + + async startCase( + serviceCase: StoredServiceCase, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const updated = await transaction.serviceCase.updateMany({ + where: { id: serviceCase.id, status: 'ASSIGNED', ownerStaffId: actor.userId }, + data: { status: 'IN_SERVICE', updatedById: actor.userId }, + }); + if (updated.count !== 1) return null; + await transaction.serviceCaseStatusHistory.create({ + data: { + caseId: serviceCase.id, + fromStatus: 'ASSIGNED', + toStatus: 'IN_SERVICE', + operatorUserId: actor.userId, + reasonCode: 'SERVICE_STARTED', + }, + }); + const applicationUpdated = await transaction.serviceApplication.updateMany({ + where: { id: serviceCase.applicationId, status: 'SERVICE_PLANNED' }, + data: { status: 'IN_SERVICE' }, + }); + if (applicationUpdated.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT'); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: serviceCase.applicationId, + fromStatus: 'SERVICE_PLANNED', + toStatus: 'IN_SERVICE', + operatorUserId: actor.userId, + reasonCode: 'SERVICE_STARTED', + }, + }); + await this.audit.record( + request, + { + action: 'SERVICE_CASE_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_CASE', + resourcePublicId: serviceCase.publicId, + metadata: { fromStatus: 'ASSIGNED', toStatus: 'IN_SERVICE' }, + }, + transaction, + ); + return transaction.serviceCase.findUniqueOrThrow({ + where: { id: serviceCase.id }, + include: caseInclude, + }); + }); + } + + async replaceCollaborators( + serviceCase: StoredServiceCase, + collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + await transaction.caseAssignee.updateMany({ + where: { caseId: serviceCase.id, role: 'COLLABORATOR', removedAt: null }, + data: { removedAt: new Date() }, + }); + for (const collaborator of collaborators) { + if (collaborator.userId === serviceCase.ownerStaffId) continue; + await transaction.caseAssignee.upsert({ + where: { caseId_userId: { caseId: serviceCase.id, userId: collaborator.userId } }, + create: { + caseId: serviceCase.id, + userId: collaborator.userId, + assigneeType: collaborator.type, + role: 'COLLABORATOR', + assignedById: actor.userId, + }, + update: { + assigneeType: collaborator.type, + role: 'COLLABORATOR', + assignedById: actor.userId, + assignedAt: new Date(), + removedAt: null, + }, + }); + } + await this.audit.record( + request, + { + action: 'SERVICE_CASE_ASSIGNEES_REPLACED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_CASE', + resourcePublicId: serviceCase.publicId, + metadata: { collaboratorCount: collaborators.length }, + }, + transaction, + ); + return transaction.serviceCase.findUniqueOrThrow({ + where: { id: serviceCase.id }, + include: caseInclude, + }); + }); + } + + findRecordByIdempotency(createdById: bigint, clientRequestId: string) { + return this.prisma.serviceRecord.findUnique({ + where: { createdById_clientRequestId: { createdById, clientRequestId } }, + include: { + creator: { include: { staffProfile: true } }, + attachments: { include: { file: true } }, + }, + }); + } + + findRecord(publicId: string) { + return this.prisma.serviceRecord.findUnique({ + where: { publicId }, + include: { + creator: { include: { staffProfile: true } }, + attachments: { include: { file: true } }, + serviceCase: { include: caseInclude }, + }, + }); + } + + async createRecord( + serviceCase: StoredServiceCase, + input: PreparedServiceRecord, + actor: AuthContext, + request: RequestContext, + ) { + return this.prisma.$transaction(async (transaction) => { + const assigned = await transaction.caseAssignee.count({ + where: { caseId: serviceCase.id, userId: actor.userId, removedAt: null }, + }); + if (!assigned || serviceCase.status !== 'IN_SERVICE') + throw new Error('CASE_RECORD_FORBIDDEN'); + const files = input.attachmentPublicIds.length + ? await transaction.file.findMany({ + where: { + publicId: { in: input.attachmentPublicIds }, + caseId: serviceCase.id, + uploaderUserId: actor.userId, + status: 'AVAILABLE', + }, + }) + : []; + if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID'); + const record = await transaction.serviceRecord.create({ + data: { + publicId: input.publicId, + caseId: serviceCase.id, + beneficiaryId: serviceCase.beneficiaryId, + clientRequestId: input.clientRequestId, + requestFingerprint: input.requestFingerprint, + serviceStartAt: input.serviceStartAt, + serviceEndAt: input.serviceEndAt, + serviceType: input.serviceType, + contentEncrypted: input.contentEncrypted, + resultEncrypted: input.resultEncrypted, + followUpSuggestionEncrypted: input.followUpSuggestionEncrypted, + beneficiaryConfirmStatus: input.beneficiaryConfirmStatus, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + if (files.length) { + await transaction.serviceRecordFile.createMany({ + data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })), + }); + } + await this.audit.record( + request, + { + action: 'SERVICE_RECORD_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_RECORD', + resourcePublicId: input.publicId, + metadata: { casePublicId: serviceCase.publicId, serviceType: input.serviceType }, + }, + transaction, + ); + return transaction.serviceRecord.findUniqueOrThrow({ + where: { id: record.id }, + include: { + creator: { include: { staffProfile: true } }, + attachments: { include: { file: true } }, + }, + }); + }); + } + + async updateRecord( + record: NonNullable>>, + input: Omit & { + expectedRevision: number; + }, + actor: AuthContext, + request: RequestContext, + ) { + return this.prisma.$transaction(async (transaction) => { + const assigned = await transaction.caseAssignee.count({ + where: { caseId: record.caseId, userId: actor.userId, removedAt: null }, + }); + if (!assigned || record.serviceCase.status !== 'IN_SERVICE') { + throw new Error('CASE_RECORD_FORBIDDEN'); + } + const files = input.attachmentPublicIds.length + ? await transaction.file.findMany({ + where: { + publicId: { in: input.attachmentPublicIds }, + caseId: record.caseId, + status: 'AVAILABLE', + OR: [ + { uploaderUserId: actor.userId }, + { serviceRecords: { some: { serviceRecordId: record.id } } }, + ], + }, + }) + : []; + if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID'); + await transaction.serviceRecordRevision.create({ + data: { + serviceRecordId: record.id, + revision: record.revision, + serviceStartAt: record.serviceStartAt, + serviceEndAt: record.serviceEndAt, + serviceType: record.serviceType, + contentEncrypted: record.contentEncrypted, + resultEncrypted: record.resultEncrypted, + followUpSuggestionEncrypted: record.followUpSuggestionEncrypted, + beneficiaryConfirmStatus: record.beneficiaryConfirmStatus, + revisedById: actor.userId, + }, + }); + const updated = await transaction.serviceRecord.updateMany({ + where: { id: record.id, revision: input.expectedRevision }, + data: { + serviceStartAt: input.serviceStartAt, + serviceEndAt: input.serviceEndAt, + serviceType: input.serviceType, + contentEncrypted: input.contentEncrypted, + resultEncrypted: input.resultEncrypted, + followUpSuggestionEncrypted: input.followUpSuggestionEncrypted, + beneficiaryConfirmStatus: input.beneficiaryConfirmStatus, + updatedById: actor.userId, + revision: { increment: 1 }, + }, + }); + if (updated.count !== 1) throw new Error('RECORD_REVISION_CONFLICT'); + await transaction.serviceRecordFile.deleteMany({ where: { serviceRecordId: record.id } }); + if (files.length) { + await transaction.serviceRecordFile.createMany({ + data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })), + }); + } + await this.audit.record( + request, + { + action: 'SERVICE_RECORD_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_RECORD', + resourcePublicId: record.publicId, + metadata: { fromRevision: record.revision, toRevision: record.revision + 1 }, + }, + transaction, + ); + return transaction.serviceRecord.findUniqueOrThrow({ + where: { id: record.id }, + include: { + creator: { include: { staffProfile: true } }, + attachments: { include: { file: true } }, + }, + }); + }); + } +} diff --git a/apps/api/src/case-management/case-management.service.ts b/apps/api/src/case-management/case-management.service.ts new file mode 100644 index 0000000..6d2ad42 --- /dev/null +++ b/apps/api/src/case-management/case-management.service.ts @@ -0,0 +1,767 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + PERMISSION_CODES, + SERVICE_CASE_PRIORITY_LABELS, + SERVICE_CASE_STATUS_LABELS, + SERVICE_RECORD_TYPE_LABELS, + type AcceptServiceApplicationResponse, + type AssigneeOption, + type BeneficiaryDetail, + type BeneficiaryDetailResponse, + type BeneficiaryListItem, + type BeneficiaryTagOption, + type PaginatedBeneficiariesResponse, + type PaginatedServiceCasesResponse, + type ServiceCaseDetail, + type ServiceCaseDetailResponse, + type ServiceCaseListItem, + type ServiceRecordItem, + type ServiceRecordResponse, + type UpdateBeneficiaryRequest, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + AcceptServiceApplicationDto, + BeneficiaryQueryDto, + CreateServiceRecordDto, + ReplaceCaseAssigneesDto, + ServiceCaseQueryDto, + TransitionServiceApplicationDto, + UpdateBeneficiaryDto, + UpdateServiceRecordDto, +} from './dto/case-management.dto'; +import { + CaseManagementRepository, + type PreparedBeneficiary, + type StoredBeneficiary, + type StoredServiceCase, +} from './case-management.repository'; + +function maskMobile(value: string): string { + return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); +} + +function shanghaiDatePrefix(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}${value.month}${value.day}`; +} + +function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +@Injectable() +export class CaseManagementService { + constructor( + @Inject(CaseManagementRepository) private readonly repository: CaseManagementRepository, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + tags(): Promise { + return this.repository + .listTags() + .then((tags) => + tags.map((tag) => ({ code: tag.code, name: tag.name, description: tag.description })), + ); + } + + async transitionApplication( + publicId: string, + input: TransitionServiceApplicationDto, + actor: AuthContext, + request: RequestContext, + ): Promise<{ status: string }> { + const expected = input.targetStatus === 'PENDING_CONTACT' ? 'SUBMITTED' : 'PENDING_CONTACT'; + const application = await this.repository.findApplication(publicId); + if (!application) + throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请'); + if (application.status !== expected) throw this.invalidTransition(); + const changed = await this.repository.transitionApplication( + publicId, + expected, + input.targetStatus, + input.reasonCode, + actor, + request, + ); + if (!changed) throw this.invalidTransition(); + return { status: input.targetStatus }; + } + + async acceptApplication( + publicId: string, + input: AcceptServiceApplicationDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const normalized = this.normalizeAcceptance(input); + const fingerprint = this.sensitiveData.lookupHash( + 'service-application-acceptance', + JSON.stringify(normalized), + ); + const application = await this.repository.findApplication(publicId); + if (!application) + throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请'); + if (application.serviceCase) { + if ( + application.serviceCase.acceptRequestId !== input.clientRequestId || + application.serviceCase.acceptRequestFingerprint !== fingerprint + ) { + throw this.duplicateRequest(); + } + return { serviceCase: this.toCaseDetail(application.serviceCase), idempotentReplay: true }; + } + if (application.status !== 'ASSESSING') throw this.invalidTransition(); + const owner = await this.repository.findOwnerCandidate(input.ownerStaffPublicId); + if (!owner) throw this.validation('负责人必须是有效工作人员'); + const collaboratorIds = [...new Set(input.collaboratorUserPublicIds)]; + if (collaboratorIds.length !== input.collaboratorUserPublicIds.length) { + throw this.validation('协作人员不能重复'); + } + const collaboratorUsers = await this.repository.findCollaboratorCandidates(collaboratorIds); + if (collaboratorUsers.length !== collaboratorIds.length) { + throw this.validation('存在无效或未获授权的协作人员'); + } + let existingBeneficiaryId: bigint | undefined; + let newBeneficiary: PreparedBeneficiary | undefined; + if (input.beneficiary.mode === 'LINK_EXISTING') { + if ( + !input.beneficiary.beneficiaryPublicId || + input.beneficiary.manualMatchConfirmed !== true + ) { + throw this.validation('关联已有档案必须完成人工核验'); + } + const beneficiary = await this.repository.findBeneficiary( + input.beneficiary.beneficiaryPublicId, + ); + if (!beneficiary) { + throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案'); + } + if (!beneficiary.mobileHash || beneficiary.mobileHash !== application.contactMobileHash) { + throw this.validation('手机号不一致,不能关联已有档案'); + } + existingBeneficiaryId = beneficiary.id; + } else { + if (!input.beneficiary.profile) throw this.validation('请填写服务对象档案'); + const profile = input.beneficiary.profile; + const guardians = profile.contacts.filter((contact) => contact.isGuardian); + if (profile.isMinor && (!guardians.length || profile.guardianConsentConfirmed !== true)) { + throw this.validation('未成年人档案必须记录监护人及其明确授权'); + } + newBeneficiary = this.prepareBeneficiary(profile, application.serviceCategory); + } + const plannedStartAt = normalized.plannedStartAt + ? new Date(normalized.plannedStartAt) + : undefined; + const plannedEndAt = normalized.plannedEndAt ? new Date(normalized.plannedEndAt) : undefined; + if (plannedStartAt && plannedEndAt && plannedEndAt < plannedStartAt) { + throw this.validation('计划结束时间不能早于开始时间'); + } + try { + const created = await this.repository.acceptApplication( + { + applicationPublicId: publicId, + acceptRequestId: input.clientRequestId, + acceptRequestFingerprint: fingerprint, + existingBeneficiaryId, + newBeneficiary, + ownerStaffId: owner.id, + collaborators: collaboratorUsers.map((user) => ({ + userId: user.id, + type: user.staffProfile ? 'STAFF' : 'VOLUNTEER', + })), + casePublicId: randomUUID(), + caseNo: `WO${shanghaiDatePrefix(new Date())}${randomBytes(5).toString('hex').toUpperCase()}`, + priority: input.priority, + plannedStartAt, + plannedEndAt, + serviceLocationEncrypted: normalized.serviceLocation + ? this.sensitiveData.encrypt(normalized.serviceLocation) + : undefined, + summaryEncrypted: this.sensitiveData.encrypt(normalized.summary), + }, + actor, + request, + ); + return { serviceCase: this.toCaseDetail(created), idempotentReplay: false }; + } catch (error) { + if (isUniqueConstraintError(error)) { + const replay = await this.repository.findApplication(publicId); + if ( + replay?.serviceCase?.acceptRequestId === input.clientRequestId && + replay.serviceCase.acceptRequestFingerprint === fingerprint + ) { + return { serviceCase: this.toCaseDetail(replay.serviceCase), idempotentReplay: true }; + } + throw this.duplicateRequest(); + } + if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') { + throw this.invalidTransition(); + } + if (error instanceof Error && error.message === 'ASSIGNEE_STATE_CONFLICT') { + throw this.validation('协作人员状态已变化,请刷新后重试'); + } + if (error instanceof Error && error.message === 'Unknown beneficiary tag') { + throw this.validation('包含未知的服务标签'); + } + throw error; + } + } + + async listBeneficiaries(query: BeneficiaryQueryDto): Promise { + const result = await this.repository.listBeneficiaries( + { + ...(query.primaryCategory ? { primaryCategory: query.primaryCategory } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.keyCareFlag === undefined ? {} : { keyCareFlag: query.keyCareFlag }), + }, + query.page, + query.pageSize, + ); + return { + items: result.items.map((item) => this.toBeneficiaryListItem(item)), + page: query.page, + pageSize: query.pageSize, + total: result.total, + }; + } + + async getBeneficiary( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + const beneficiary = await this.repository.findBeneficiary(publicId); + if (!beneficiary) { + throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案'); + } + await this.audit.record(request, { + action: 'BENEFICIARY_DETAIL_VIEWED', + result: 'SUCCESS', + actor, + resourceType: 'BENEFICIARY', + resourcePublicId: publicId, + }); + return { beneficiary: this.toBeneficiaryDetail(beneficiary) }; + } + + async updateBeneficiary( + publicId: string, + input: UpdateBeneficiaryDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const existing = await this.repository.findBeneficiary(publicId); + if (!existing) { + throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案'); + } + const guardians = input.contacts.filter((contact) => contact.isGuardian); + if (input.isMinor && (!guardians.length || input.guardianConsentConfirmed !== true)) { + throw this.validation('未成年人档案必须记录监护人及其明确授权'); + } + try { + const prepared = this.prepareBeneficiary(input, existing.primaryCategory); + const updated = await this.repository.updateBeneficiary( + { ...existing }, + { ...prepared, status: input.status }, + actor, + request, + ); + return { beneficiary: this.toBeneficiaryDetail(updated) }; + } catch (error) { + if (error instanceof Error && error.message === 'Unknown beneficiary tag') { + throw this.validation('包含未知的服务标签'); + } + throw error; + } + } + + async assigneeOptions(): Promise { + const users = await this.repository.listAssigneeOptions(); + return users.map((user) => ({ + userPublicId: user.publicId, + displayName: user.staffProfile?.displayName ?? user.nickname ?? '志愿者', + type: user.staffProfile ? 'STAFF' : 'VOLUNTEER', + department: user.staffProfile?.department ?? null, + })); + } + + async listCases( + query: ServiceCaseQueryDto, + actor: AuthContext, + ): Promise { + const result = await this.repository.listCases( + { + ...(query.status ? { status: query.status } : {}), + ...(query.category ? { category: query.category } : {}), + ...(query.caseNo?.trim() ? { caseNo: query.caseNo.trim().toUpperCase() } : {}), + }, + actor.userId, + actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL), + query.page, + query.pageSize, + ); + return { + items: result.items.map((item) => this.toCaseListItem(item)), + page: query.page, + pageSize: query.pageSize, + total: result.total, + }; + } + + async getCase( + publicId: string, + actor: AuthContext, + request: RequestContext, + minimal = false, + ): Promise { + const serviceCase = await this.requireAccessibleCase(publicId, actor); + await this.audit.record(request, { + action: minimal ? 'ASSIGNED_CASE_DETAIL_VIEWED' : 'SERVICE_CASE_DETAIL_VIEWED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_CASE', + resourcePublicId: publicId, + }); + return { serviceCase: this.toCaseDetail(serviceCase, minimal) }; + } + + async startCase( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + const serviceCase = await this.requireAccessibleCase(publicId, actor); + if (serviceCase.status !== 'ASSIGNED' || serviceCase.ownerStaffId !== actor.userId) { + throw this.invalidTransition(); + } + try { + const updated = await this.repository.startCase(serviceCase, actor, request); + if (!updated) throw this.invalidTransition(); + return { serviceCase: this.toCaseDetail(updated) }; + } catch (error) { + if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') { + throw this.invalidTransition(); + } + throw error; + } + } + + async replaceAssignees( + publicId: string, + input: ReplaceCaseAssigneesDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const serviceCase = await this.requireAccessibleCase(publicId, actor); + const publicIds = [...new Set(input.collaboratorUserPublicIds)]; + if (publicIds.length !== input.collaboratorUserPublicIds.length) { + throw this.validation('协作人员不能重复'); + } + const users = await this.repository.findCollaboratorCandidates(publicIds); + if (users.length !== publicIds.length) throw this.validation('存在无效或未获授权的协作人员'); + const updated = await this.repository.replaceCollaborators( + serviceCase, + users.map((user) => ({ userId: user.id, type: user.staffProfile ? 'STAFF' : 'VOLUNTEER' })), + actor, + request, + ); + return { serviceCase: this.toCaseDetail(updated) }; + } + + async createRecord( + casePublicId: string, + input: CreateServiceRecordDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const serviceCase = await this.requireAssignedCase(casePublicId, actor); + const normalized = { + serviceStartAt: input.serviceStartAt, + serviceEndAt: input.serviceEndAt ?? null, + serviceType: input.serviceType, + content: input.content.trim(), + result: input.result.trim(), + followUpSuggestion: input.followUpSuggestion?.trim() || null, + beneficiaryConfirmStatus: input.beneficiaryConfirmStatus, + attachmentPublicIds: [...new Set(input.attachmentPublicIds)].sort(), + }; + const fingerprint = this.sensitiveData.lookupHash( + 'service-record-request', + JSON.stringify(normalized), + ); + const previous = await this.repository.findRecordByIdempotency( + actor.userId, + input.clientRequestId, + ); + if (previous) { + if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest(); + return { record: this.toServiceRecord(previous), idempotentReplay: true }; + } + const start = new Date(input.serviceStartAt); + const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined; + if (end && end < start) throw this.validation('服务结束时间不能早于开始时间'); + if (serviceCase.status !== 'IN_SERVICE') throw this.invalidTransition(); + try { + const record = await this.repository.createRecord( + serviceCase, + { + publicId: randomUUID(), + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + serviceStartAt: start, + serviceEndAt: end, + serviceType: input.serviceType, + contentEncrypted: this.sensitiveData.encrypt(normalized.content), + resultEncrypted: this.sensitiveData.encrypt(normalized.result), + followUpSuggestionEncrypted: normalized.followUpSuggestion + ? this.sensitiveData.encrypt(normalized.followUpSuggestion) + : undefined, + beneficiaryConfirmStatus: input.beneficiaryConfirmStatus, + attachmentPublicIds: normalized.attachmentPublicIds, + }, + actor, + request, + ); + return { record: this.toServiceRecord(record), idempotentReplay: false }; + } catch (error) { + if (error instanceof Error && error.message === 'FILE_INVALID') { + throw this.validation('附件不存在、尚未确认或不属于当前提交人'); + } + if (error instanceof Error && error.message === 'CASE_RECORD_FORBIDDEN') { + throw this.invalidTransition(); + } + throw error; + } + } + + async updateRecord( + casePublicId: string, + recordPublicId: string, + input: UpdateServiceRecordDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const serviceCase = await this.requireAssignedCase(casePublicId, actor); + const record = await this.repository.findRecord(recordPublicId); + if (!record || record.caseId !== serviceCase.id) { + throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录'); + } + if ( + record.createdById !== actor.userId && + serviceCase.ownerStaffId !== actor.userId && + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ) { + throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录'); + } + const start = new Date(input.serviceStartAt); + const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined; + if (end && end < start) throw this.validation('服务结束时间不能早于开始时间'); + try { + const updated = await this.repository.updateRecord( + record, + { + expectedRevision: input.expectedRevision, + serviceStartAt: start, + serviceEndAt: end, + serviceType: input.serviceType, + contentEncrypted: this.sensitiveData.encrypt(input.content.trim()), + resultEncrypted: this.sensitiveData.encrypt(input.result.trim()), + followUpSuggestionEncrypted: input.followUpSuggestion?.trim() + ? this.sensitiveData.encrypt(input.followUpSuggestion.trim()) + : undefined, + beneficiaryConfirmStatus: input.beneficiaryConfirmStatus, + attachmentPublicIds: [...new Set(input.attachmentPublicIds)], + }, + actor, + request, + ); + return { record: this.toServiceRecord(updated) }; + } catch (error) { + if (error instanceof Error && error.message === 'RECORD_REVISION_CONFLICT') { + throw new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + '服务记录已被更新,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + if (error instanceof Error && error.message === 'FILE_INVALID') { + throw this.validation('附件不存在、尚未确认或无权关联'); + } + throw error; + } + } + + async requireAssignedCase(publicId: string, actor: AuthContext): Promise { + const serviceCase = await this.repository.findCase(publicId); + if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId); + if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + return serviceCase; + } + + async requireAssignedCaseByIdForFile( + caseId: bigint, + actor: AuthContext, + ): Promise { + const serviceCase = await this.repository.findCaseById(caseId); + if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) { + const assigned = await this.repository.isAssigned(caseId, actor.userId); + if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + } + return serviceCase; + } + + private async requireAccessibleCase( + publicId: string, + actor: AuthContext, + ): Promise { + const serviceCase = await this.repository.findCase(publicId); + if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) { + const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId); + if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单'); + } + return serviceCase; + } + + private normalizeAcceptance(input: AcceptServiceApplicationDto) { + return { + clientRequestId: input.clientRequestId, + beneficiary: input.beneficiary, + ownerStaffPublicId: input.ownerStaffPublicId, + collaboratorUserPublicIds: [...input.collaboratorUserPublicIds].sort(), + priority: input.priority, + plannedStartAt: input.plannedStartAt ?? null, + plannedEndAt: input.plannedEndAt ?? null, + serviceLocation: input.serviceLocation?.trim() || null, + summary: input.summary.trim(), + }; + } + + private prepareBeneficiary( + profile: + NonNullable | UpdateBeneficiaryRequest, + primaryCategory: PreparedBeneficiary['primaryCategory'], + ): PreparedBeneficiary { + const displayName = profile.displayName.trim(); + const mobile = profile.mobile?.trim(); + return { + publicId: randomUUID(), + displayNameEncrypted: this.sensitiveData.encrypt(displayName), + displayNameHash: this.sensitiveData.lookupHash('beneficiary-display-name', displayName), + gender: profile.gender, + birthYear: profile.birthYear, + mobileEncrypted: mobile ? this.sensitiveData.encrypt(mobile) : undefined, + mobileHash: mobile ? this.sensitiveData.lookupHash('beneficiary-mobile', mobile) : undefined, + regionCode: profile.regionCode.trim(), + addressDetailEncrypted: profile.addressDetail?.trim() + ? this.sensitiveData.encrypt(profile.addressDetail.trim()) + : undefined, + primaryCategory, + keyCareFlag: profile.keyCareFlag, + isMinor: profile.isMinor, + notesEncrypted: profile.notes?.trim() + ? this.sensitiveData.encrypt(profile.notes.trim()) + : undefined, + contacts: profile.contacts.map((contact) => ({ + publicId: randomUUID(), + nameEncrypted: this.sensitiveData.encrypt(contact.name.trim()), + relation: contact.relation.trim(), + mobileEncrypted: this.sensitiveData.encrypt(contact.mobile.trim()), + mobileHash: this.sensitiveData.lookupHash( + 'beneficiary-contact-mobile', + contact.mobile.trim(), + ), + isGuardian: contact.isGuardian, + isEmergencyContact: contact.isEmergencyContact, + })), + tagCodes: [...new Set(profile.tagCodes)], + guardianConsentConfirmed: profile.guardianConsentConfirmed === true, + }; + } + + private toBeneficiaryListItem(beneficiary: StoredBeneficiary): BeneficiaryListItem { + const mobile = beneficiary.mobileEncrypted + ? this.sensitiveData.decrypt(beneficiary.mobileEncrypted) + : null; + return { + publicId: beneficiary.publicId, + displayName: this.sensitiveData.decrypt(beneficiary.displayNameEncrypted), + gender: beneficiary.gender, + birthYear: beneficiary.birthYear, + mobileMasked: mobile ? maskMobile(mobile) : null, + regionCode: beneficiary.regionCode, + primaryCategory: beneficiary.primaryCategory, + keyCareFlag: beneficiary.keyCareFlag, + isMinor: beneficiary.isMinor, + status: beneficiary.status, + tags: beneficiary.beneficiaryTags.map(({ tag }) => ({ + code: tag.code, + name: tag.name, + description: tag.description, + })), + createdAt: beneficiary.createdAt.toISOString(), + }; + } + + private toBeneficiaryDetail(beneficiary: StoredBeneficiary): BeneficiaryDetail { + const mobile = beneficiary.mobileEncrypted + ? this.sensitiveData.decrypt(beneficiary.mobileEncrypted) + : null; + return { + ...this.toBeneficiaryListItem(beneficiary), + mobile, + addressDetail: beneficiary.addressDetailEncrypted + ? this.sensitiveData.decrypt(beneficiary.addressDetailEncrypted) + : null, + notes: beneficiary.notesEncrypted + ? this.sensitiveData.decrypt(beneficiary.notesEncrypted) + : null, + contacts: beneficiary.contacts.map((contact) => { + const contactMobile = this.sensitiveData.decrypt(contact.mobileEncrypted); + return { + publicId: contact.publicId, + name: this.sensitiveData.decrypt(contact.nameEncrypted), + relation: contact.relation, + mobile: contactMobile, + mobileMasked: maskMobile(contactMobile), + isGuardian: contact.isGuardian, + isEmergencyContact: contact.isEmergencyContact, + hasConsentRecord: contact.consentRecordId !== null, + }; + }), + serviceCaseCount: beneficiary._count.serviceCases, + }; + } + + private toCaseListItem(serviceCase: StoredServiceCase): ServiceCaseListItem { + return { + publicId: serviceCase.publicId, + caseNo: serviceCase.caseNo, + applicationPublicId: serviceCase.application.publicId, + applicationNo: serviceCase.application.applicationNo, + beneficiaryPublicId: serviceCase.beneficiary.publicId, + beneficiaryDisplayName: this.sensitiveData.decrypt( + serviceCase.beneficiary.displayNameEncrypted, + ), + category: serviceCase.category, + priority: serviceCase.priority, + priorityLabel: SERVICE_CASE_PRIORITY_LABELS[serviceCase.priority], + status: serviceCase.status, + statusLabel: SERVICE_CASE_STATUS_LABELS[serviceCase.status], + ownerDisplayName: serviceCase.owner.staffProfile?.displayName ?? '工作人员', + plannedStartAt: serviceCase.plannedStartAt?.toISOString() ?? null, + createdAt: serviceCase.createdAt.toISOString(), + }; + } + + private toCaseDetail(serviceCase: StoredServiceCase, minimal = false): ServiceCaseDetail { + const mobile = serviceCase.beneficiary.mobileEncrypted + ? this.sensitiveData.decrypt(serviceCase.beneficiary.mobileEncrypted) + : null; + return { + ...this.toCaseListItem(serviceCase), + regionCode: serviceCase.beneficiary.regionCode, + beneficiaryMobile: minimal ? null : mobile, + beneficiaryMobileMasked: mobile ? maskMobile(mobile) : null, + serviceLocation: serviceCase.serviceLocationEncrypted + ? this.sensitiveData.decrypt(serviceCase.serviceLocationEncrypted) + : null, + summary: this.sensitiveData.decrypt(serviceCase.summaryEncrypted), + resultSummary: serviceCase.resultSummaryEncrypted + ? this.sensitiveData.decrypt(serviceCase.resultSummaryEncrypted) + : null, + followUpPlan: serviceCase.followUpPlanEncrypted + ? this.sensitiveData.decrypt(serviceCase.followUpPlanEncrypted) + : null, + completedAt: serviceCase.completedAt?.toISOString() ?? null, + assignees: serviceCase.assignees.map((assignee) => ({ + userPublicId: assignee.user.publicId, + displayName: assignee.user.staffProfile?.displayName ?? assignee.user.nickname ?? '志愿者', + type: assignee.assigneeType, + department: assignee.user.staffProfile?.department ?? null, + role: assignee.role, + assignedAt: assignee.assignedAt.toISOString(), + })), + history: serviceCase.statusHistory.map((history) => ({ + status: history.toStatus, + statusLabel: SERVICE_CASE_STATUS_LABELS[history.toStatus], + reasonLabel: + history.reasonCode === 'CASE_CREATED_AND_ASSIGNED' + ? '工单已创建并指派' + : history.reasonCode === 'SERVICE_COMPLETED' + ? '本阶段服务已完成并创建回访' + : '服务已开始', + createdAt: history.createdAt.toISOString(), + })), + records: serviceCase.serviceRecords.map((record) => this.toServiceRecord(record)), + }; + } + + private toServiceRecord(record: StoredServiceCase['serviceRecords'][number]): ServiceRecordItem { + return { + publicId: record.publicId, + serviceStartAt: record.serviceStartAt.toISOString(), + serviceEndAt: record.serviceEndAt?.toISOString() ?? null, + serviceType: record.serviceType, + serviceTypeLabel: SERVICE_RECORD_TYPE_LABELS[record.serviceType], + content: this.sensitiveData.decrypt(record.contentEncrypted), + result: this.sensitiveData.decrypt(record.resultEncrypted), + followUpSuggestion: record.followUpSuggestionEncrypted + ? this.sensitiveData.decrypt(record.followUpSuggestionEncrypted) + : null, + beneficiaryConfirmStatus: record.beneficiaryConfirmStatus, + createdByDisplayName: + record.creator.staffProfile?.displayName ?? record.creator.nickname ?? '志愿者', + revision: record.revision, + attachments: record.attachments.map(({ file }) => ({ + publicId: file.publicId, + filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted), + mimeType: file.mimeType, + size: Number(file.size), + })), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; + } + + private invalidTransition(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + '当前状态不能执行此操作,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + + private duplicateRequest(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '本次提交标识已用于其他内容,请重新操作', + HttpStatus.CONFLICT, + ); + } + + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + + private notFound(code: string, message: string): ApplicationError { + return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND); + } +} diff --git a/apps/api/src/case-management/dto/case-management.dto.ts b/apps/api/src/case-management/dto/case-management.dto.ts new file mode 100644 index 0000000..eec91f4 --- /dev/null +++ b/apps/api/src/case-management/dto/case-management.dto.ts @@ -0,0 +1,331 @@ +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Length, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + +import { + BENEFICIARY_CONFIRM_STATUSES, + BENEFICIARY_GENDERS, + BENEFICIARY_STATUSES, + SERVICE_CASE_PRIORITIES, + SERVICE_CASE_STATUSES, + SERVICE_CATEGORIES, + SERVICE_RECORD_TYPES, +} from '@lehuo/contracts'; + +const MOBILE_PATTERN = /^1[3-9]\d{9}$/; +const TAG_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/; + +export class PaginationDto { + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 20; +} + +export class BeneficiaryContactDto { + @IsString() + @Length(1, 50) + name!: string; + + @IsString() + @Length(1, 50) + relation!: string; + + @Matches(MOBILE_PATTERN) + mobile!: string; + + @IsBoolean() + isGuardian!: boolean; + + @IsBoolean() + isEmergencyContact!: boolean; +} + +export class BeneficiaryProfileDto { + @IsString() + @Length(1, 50) + displayName!: string; + + @IsOptional() + @IsIn(BENEFICIARY_GENDERS) + gender?: (typeof BENEFICIARY_GENDERS)[number]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1900) + @Max(2200) + birthYear?: number; + + @IsOptional() + @Matches(MOBILE_PATTERN) + mobile?: string; + + @IsString() + @Length(2, 100) + regionCode!: string; + + @IsOptional() + @IsString() + @MaxLength(300) + addressDetail?: string; + + @IsBoolean() + keyCareFlag!: boolean; + + @IsBoolean() + isMinor!: boolean; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; + + @IsArray() + @ArrayMaxSize(10) + @ValidateNested({ each: true }) + @Type(() => BeneficiaryContactDto) + contacts!: BeneficiaryContactDto[]; + + @IsArray() + @ArrayMaxSize(20) + @Matches(TAG_CODE_PATTERN, { each: true }) + tagCodes!: string[]; + + @IsOptional() + @IsIn([true]) + guardianConsentConfirmed?: true; +} + +export class BeneficiarySelectionDto { + @IsIn(['CREATE', 'LINK_EXISTING']) + mode!: 'CREATE' | 'LINK_EXISTING'; + + @IsOptional() + @ValidateNested() + @Type(() => BeneficiaryProfileDto) + profile?: BeneficiaryProfileDto; + + @IsOptional() + @IsUUID('4') + beneficiaryPublicId?: string; + + @IsOptional() + @IsIn([true]) + manualMatchConfirmed?: true; +} + +export class AcceptServiceApplicationDto { + @IsUUID('4') + clientRequestId!: string; + + @ValidateNested() + @Type(() => BeneficiarySelectionDto) + beneficiary!: BeneficiarySelectionDto; + + @IsUUID('4') + ownerStaffPublicId!: string; + + @IsArray() + @ArrayMaxSize(20) + @IsUUID('4', { each: true }) + collaboratorUserPublicIds!: string[]; + + @IsIn(SERVICE_CASE_PRIORITIES) + priority!: (typeof SERVICE_CASE_PRIORITIES)[number]; + + @IsOptional() + @IsISO8601({ strict: true }) + plannedStartAt?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + plannedEndAt?: string; + + @IsOptional() + @IsString() + @MaxLength(300) + serviceLocation?: string; + + @IsString() + @Length(10, 2000) + summary!: string; +} + +export class TransitionServiceApplicationDto { + @IsIn(['PENDING_CONTACT', 'ASSESSING']) + targetStatus!: 'PENDING_CONTACT' | 'ASSESSING'; + + @IsString() + @Matches(/^[A-Z][A-Z0-9_]{2,99}$/) + reasonCode!: string; +} + +export class BeneficiaryQueryDto extends PaginationDto { + @IsOptional() + @IsIn(SERVICE_CATEGORIES) + primaryCategory?: (typeof SERVICE_CATEGORIES)[number]; + + @IsOptional() + @IsIn(BENEFICIARY_STATUSES) + status?: (typeof BENEFICIARY_STATUSES)[number]; + + @IsOptional() + @Transform(({ value }) => { + const candidate: unknown = value; + if (candidate === 'true' || candidate === true) return true; + if (candidate === 'false' || candidate === false) return false; + return candidate; + }) + @IsBoolean() + keyCareFlag?: boolean; +} + +export class UpdateBeneficiaryDto extends BeneficiaryProfileDto { + @IsIn(BENEFICIARY_STATUSES) + status!: (typeof BENEFICIARY_STATUSES)[number]; +} + +export class ServiceCaseQueryDto extends PaginationDto { + @IsOptional() + @IsIn(SERVICE_CASE_STATUSES) + status?: (typeof SERVICE_CASE_STATUSES)[number]; + + @IsOptional() + @IsIn(SERVICE_CATEGORIES) + category?: (typeof SERVICE_CATEGORIES)[number]; + + @IsOptional() + @IsString() + @MaxLength(32) + caseNo?: string; +} + +export class ReplaceCaseAssigneesDto { + @IsArray() + @ArrayMaxSize(20) + @IsUUID('4', { each: true }) + collaboratorUserPublicIds!: string[]; +} + +export class CreateServiceRecordDto { + @IsUUID('4') + clientRequestId!: string; + + @IsISO8601({ strict: true }) + serviceStartAt!: string; + + @IsOptional() + @IsISO8601({ strict: true }) + serviceEndAt?: string; + + @IsIn(SERVICE_RECORD_TYPES) + serviceType!: (typeof SERVICE_RECORD_TYPES)[number]; + + @IsString() + @MinLength(2) + @MaxLength(5000) + content!: string; + + @IsString() + @MinLength(2) + @MaxLength(5000) + result!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + followUpSuggestion?: string; + + @IsIn(BENEFICIARY_CONFIRM_STATUSES) + beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number]; + + @IsArray() + @ArrayMaxSize(9) + @IsUUID('4', { each: true }) + attachmentPublicIds!: string[]; +} + +export class UpdateServiceRecordDto { + @Type(() => Number) + @IsInt() + @Min(1) + expectedRevision!: number; + + @IsISO8601({ strict: true }) + serviceStartAt!: string; + + @IsOptional() + @IsISO8601({ strict: true }) + serviceEndAt?: string; + + @IsIn(SERVICE_RECORD_TYPES) + serviceType!: (typeof SERVICE_RECORD_TYPES)[number]; + + @IsString() + @MinLength(2) + @MaxLength(5000) + content!: string; + + @IsString() + @MinLength(2) + @MaxLength(5000) + result!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + followUpSuggestion?: string; + + @IsIn(BENEFICIARY_CONFIRM_STATUSES) + beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number]; + + @IsArray() + @ArrayMaxSize(9) + @IsUUID('4', { each: true }) + attachmentPublicIds!: string[]; +} + +export class CreateUploadIntentDto { + @IsUUID('4') + clientRequestId!: string; + + @IsString() + @Length(1, 255) + filename!: string; + + @IsIn(['image/jpeg', 'image/png', 'image/webp']) + mimeType!: 'image/jpeg' | 'image/png' | 'image/webp'; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10 * 1024 * 1024) + size!: number; + + @IsOptional() + @Matches(/^[a-f0-9]{64}$/) + sha256?: string; +} diff --git a/apps/api/src/common/api-exception.filter.ts b/apps/api/src/common/api-exception.filter.ts new file mode 100644 index 0000000..cfd204f --- /dev/null +++ b/apps/api/src/common/api-exception.filter.ts @@ -0,0 +1,100 @@ +import { + ArgumentsHost, + BadRequestException, + Catch, + ForbiddenException, + HttpException, + Logger, + UnauthorizedException, + type ExceptionFilter, +} from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import type { Response } from 'express'; + +import { API_ERROR_CODES, type ApiErrorCode, type ApiErrorResponse } from '@lehuo/contracts'; + +import { ApplicationError } from './application-error'; +import type { RequestContext } from './request-context'; + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ApiExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + const mapped = this.mapException(exception); + if (mapped.status >= 500) { + const name = exception instanceof Error ? exception.name : 'UnknownError'; + this.logger.error(`Unhandled ${name}; requestId=${request.requestId}`); + } + response.status(mapped.status).json({ + success: false, + error: { + code: mapped.code, + message: mapped.message, + ...(mapped.details ? { details: mapped.details } : {}), + }, + requestId: request.requestId, + } satisfies ApiErrorResponse); + } + + private mapException(exception: unknown): { + status: number; + code: ApiErrorCode; + message: string; + details?: Record; + } { + if (exception instanceof ApplicationError) { + return { + status: exception.getStatus(), + code: exception.code, + message: exception.message, + ...(exception.details ? { details: exception.details } : {}), + }; + } + if (exception instanceof ThrottlerException) { + return { + status: 429, + code: API_ERROR_CODES.RATE_LIMITED, + message: '操作过于频繁,请稍后再试', + }; + } + if (exception instanceof UnauthorizedException) { + return { status: 401, code: API_ERROR_CODES.AUTH_REQUIRED, message: '请先登录后再继续' }; + } + if (exception instanceof ForbiddenException) { + return { status: 403, code: API_ERROR_CODES.FORBIDDEN, message: '您暂无权限执行此操作' }; + } + if (exception instanceof BadRequestException) { + const response = exception.getResponse(); + const messages = + typeof response === 'object' && response && 'message' in response ? response.message : null; + return { + status: 400, + code: API_ERROR_CODES.VALIDATION_ERROR, + message: '请检查填写内容后重试', + ...(Array.isArray(messages) + ? { + details: { + fields: messages.filter((item): item is string => typeof item === 'string'), + }, + } + : {}), + }; + } + if (exception instanceof HttpException) { + return { + status: exception.getStatus(), + code: API_ERROR_CODES.INTERNAL_ERROR, + message: exception.getStatus() >= 500 ? '服务暂时不可用,请稍后重试' : exception.message, + }; + } + return { + status: 500, + code: API_ERROR_CODES.INTERNAL_ERROR, + message: '服务暂时不可用,请稍后重试', + }; + } +} diff --git a/apps/api/src/common/api-response.interceptor.ts b/apps/api/src/common/api-response.interceptor.ts new file mode 100644 index 0000000..128ba8f --- /dev/null +++ b/apps/api/src/common/api-response.interceptor.ts @@ -0,0 +1,36 @@ +import { + Inject, + Injectable, + type CallHandler, + type ExecutionContext, + type NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import type { ApiSuccessResponse } from '@lehuo/contracts'; + +import { RAW_RESPONSE_KEY } from './decorators/raw-response.decorator'; +import type { RequestContext } from './request-context'; + +@Injectable() +export class ApiResponseInterceptor implements NestInterceptor { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const raw = this.reflector.getAllAndOverride(RAW_RESPONSE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (raw) return next.handle(); + const request = context.switchToHttp().getRequest(); + return next.handle().pipe( + map((data: unknown): ApiSuccessResponse => ({ + success: true, + data, + requestId: request.requestId, + })), + ); + } +} diff --git a/apps/api/src/common/application-error.ts b/apps/api/src/common/application-error.ts new file mode 100644 index 0000000..7c855da --- /dev/null +++ b/apps/api/src/common/application-error.ts @@ -0,0 +1,14 @@ +import { HttpException, type HttpStatus } from '@nestjs/common'; + +import type { ApiErrorCode } from '@lehuo/contracts'; + +export class ApplicationError extends HttpException { + constructor( + readonly code: ApiErrorCode, + message: string, + status: HttpStatus, + readonly details?: Record, + ) { + super(message, status); + } +} diff --git a/apps/api/src/common/decorators/current-auth.decorator.ts b/apps/api/src/common/decorators/current-auth.decorator.ts new file mode 100644 index 0000000..87e8300 --- /dev/null +++ b/apps/api/src/common/decorators/current-auth.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; + +import type { AuthContext, RequestContext } from '../request-context'; + +export const CurrentAuth = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthContext => { + const request = context.switchToHttp().getRequest(); + if (!request.auth) throw new Error('Authentication context is unavailable'); + return request.auth; + }, +); diff --git a/apps/api/src/common/decorators/public.decorator.ts b/apps/api/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..089f550 --- /dev/null +++ b/apps/api/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/common/decorators/raw-response.decorator.ts b/apps/api/src/common/decorators/raw-response.decorator.ts new file mode 100644 index 0000000..18e7780 --- /dev/null +++ b/apps/api/src/common/decorators/raw-response.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const RAW_RESPONSE_KEY = 'rawResponse'; +export const RawResponse = (): MethodDecorator & ClassDecorator => + SetMetadata(RAW_RESPONSE_KEY, true); diff --git a/apps/api/src/common/decorators/require-permissions.decorator.ts b/apps/api/src/common/decorators/require-permissions.decorator.ts new file mode 100644 index 0000000..22629dd --- /dev/null +++ b/apps/api/src/common/decorators/require-permissions.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +import type { PermissionCode } from '@lehuo/contracts'; + +export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions'; +export const RequirePermissions = ( + ...permissions: PermissionCode[] +): MethodDecorator & ClassDecorator => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions); diff --git a/apps/api/src/common/request-context.ts b/apps/api/src/common/request-context.ts new file mode 100644 index 0000000..9d57a73 --- /dev/null +++ b/apps/api/src/common/request-context.ts @@ -0,0 +1,23 @@ +import type { Request } from 'express'; + +import type { PermissionCode, RoleCode, UserStatus, UserType } from '@lehuo/contracts'; + +import type { SessionChannel } from '../generated/prisma/enums'; + +export interface AuthContext { + userId: bigint; + userPublicId: string; + status: UserStatus; + userType: UserType; + displayName: string | null; + roles: RoleCode[]; + permissions: PermissionCode[]; + sessionId: bigint; + channel: SessionChannel; + rawToken: string; +} + +export interface RequestContext extends Request { + requestId: string; + auth?: AuthContext; +} diff --git a/apps/api/src/common/request-id.middleware.ts b/apps/api/src/common/request-id.middleware.ts new file mode 100644 index 0000000..ae8d1b1 --- /dev/null +++ b/apps/api/src/common/request-id.middleware.ts @@ -0,0 +1,17 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Response } from 'express'; + +import type { RequestContext } from './request-context'; + +const REQUEST_ID_PATTERN = /^[a-zA-Z0-9_-]{8,64}$/; + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(request: RequestContext, response: Response, next: NextFunction): void { + const supplied = request.header('x-request-id'); + request.requestId = supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID(); + response.setHeader('x-request-id', request.requestId); + next(); + } +} diff --git a/apps/api/src/common/request-metrics.interceptor.ts b/apps/api/src/common/request-metrics.interceptor.ts new file mode 100644 index 0000000..0886c2b --- /dev/null +++ b/apps/api/src/common/request-metrics.interceptor.ts @@ -0,0 +1,33 @@ +import { + Inject, + Injectable, + type CallHandler, + type ExecutionContext, + type NestInterceptor, +} from '@nestjs/common'; +import { catchError, finalize, type Observable, throwError } from 'rxjs'; +import { RequestMetricsService } from './request-metrics.service'; +@Injectable() +export class RequestMetricsInterceptor implements NestInterceptor { + constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {} + intercept(context: ExecutionContext, next: CallHandler): Observable { + const start = performance.now(); + let status = 200; + return next.handle().pipe( + catchError((e: unknown) => { + status = + typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number' + ? e.status + : 500; + return throwError(() => e); + }), + finalize(() => { + const response = context.switchToHttp().getResponse<{ statusCode: number }>(); + this.metrics.record( + status === 200 ? response.statusCode : status, + performance.now() - start, + ); + }), + ); + } +} diff --git a/apps/api/src/common/request-metrics.service.ts b/apps/api/src/common/request-metrics.service.ts new file mode 100644 index 0000000..4f92ebf --- /dev/null +++ b/apps/api/src/common/request-metrics.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +@Injectable() +export class RequestMetricsService { + private count = 0; + private errors = 0; + private durations: number[] = []; + record(status: number, duration: number) { + this.count++; + if (status >= 500) this.errors++; + this.durations.push(duration); + if (this.durations.length > 1000) this.durations.shift(); + } + snapshot() { + const sorted = [...this.durations].sort((a, b) => a - b); + return { + requestCount: this.count, + serverErrorCount: this.errors, + p95Milliseconds: sorted[Math.floor(sorted.length * 0.95)] ?? 0, + sampleSize: sorted.length, + }; + } +} diff --git a/apps/api/src/common/system-metrics.controller.ts b/apps/api/src/common/system-metrics.controller.ts new file mode 100644 index 0000000..38deffe --- /dev/null +++ b/apps/api/src/common/system-metrics.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { PERMISSION_CODES } from '@lehuo/contracts'; +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { RequirePermissions } from './decorators/require-permissions.decorator'; +import { SessionChannel } from '../generated/prisma/enums'; +import { RequestMetricsService } from './request-metrics.service'; +@Controller('api/v1/admin/system') +@RequireSessionChannel(SessionChannel.ADMIN) +export class SystemMetricsController { + constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {} + @Get('metrics') @RequirePermissions(PERMISSION_CODES.SYSTEM_MANAGE) get() { + return { + ...this.metrics.snapshot(), + uptimeSeconds: Math.floor(process.uptime()), + memory: process.memoryUsage().rss, + }; + } +} diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts new file mode 100644 index 0000000..f967695 --- /dev/null +++ b/apps/api/src/config/app-config.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { AppConfigService } from './app-config.service'; + +@Global() +@Module({ + providers: [AppConfigService], + exports: [AppConfigService], +}) +export class AppConfigModule {} diff --git a/apps/api/src/config/app-config.service.spec.ts b/apps/api/src/config/app-config.service.spec.ts new file mode 100644 index 0000000..143ea5b --- /dev/null +++ b/apps/api/src/config/app-config.service.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { AppConfigService } from './app-config.service'; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +function setValidEnvironment(): void { + process.env.DATABASE_URL = 'mysql://user:password@localhost:3306/lehuo_test'; + process.env.SESSION_TOKEN_PEPPER = 'test-session-pepper-with-32-characters'; + process.env.AUDIT_IP_HASH_KEY = 'test-audit-key-with-at-least-32-chars'; + process.env.ADMIN_WEB_ORIGIN = 'http://localhost:5173'; + process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64'); +} + +describe('AppConfigService', () => { + it('parses an explicit MySQL configuration', () => { + setValidEnvironment(); + const config = new AppConfigService(); + + expect(config.database).toEqual({ + host: 'localhost', + port: 3306, + user: 'user', + password: 'password', + database: 'lehuo_test', + }); + }); + + it('rejects encryption keys that are not 32 bytes', () => { + setValidEnvironment(); + process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(16).toString('base64'); + + expect(() => new AppConfigService()).toThrow(/32-byte key/); + }); +}); diff --git a/apps/api/src/config/app-config.service.ts b/apps/api/src/config/app-config.service.ts new file mode 100644 index 0000000..01c5ca6 --- /dev/null +++ b/apps/api/src/config/app-config.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; + +export interface DatabaseConnectionOptions { + host: string; + port: number; + user: string; + password: string; + database: string; +} + +function requiredEnvironmentValue(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function positiveInteger(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +function parseBoolean(name: string, fallback: boolean): boolean { + const raw = process.env[name]?.trim().toLowerCase(); + if (!raw) return fallback; + if (raw === 'true') return true; + if (raw === 'false') return false; + throw new Error(`${name} must be true or false`); +} + +@Injectable() +export class AppConfigService { + readonly appEnvironment = process.env.APP_ENV?.trim() || 'development'; + readonly apiPort = positiveInteger('API_PORT', 3000); + readonly databaseUrl = requiredEnvironmentValue('DATABASE_URL'); + readonly sessionTokenPepper = requiredEnvironmentValue('SESSION_TOKEN_PEPPER'); + readonly auditIpHashKey = requiredEnvironmentValue('AUDIT_IP_HASH_KEY'); + readonly adminWebOrigin = requiredEnvironmentValue('ADMIN_WEB_ORIGIN'); + readonly adminCookieSecure = parseBoolean('ADMIN_COOKIE_SECURE', true); + readonly adminSessionTtlSeconds = positiveInteger('AUTH_SESSION_TTL_SECONDS', 28_800); + readonly wechatSessionTtlSeconds = positiveInteger('WECHAT_SESSION_TTL_SECONDS', 7_200); + readonly wechatAppId = process.env.WECHAT_APP_ID?.trim() || null; + readonly wechatAppSecret = process.env.WECHAT_APP_SECRET?.trim() || null; + readonly wechatNotificationTemplates = { + APPLICATION_SUBMITTED: process.env.WECHAT_TEMPLATE_APPLICATION_SUBMITTED?.trim() || null, + APPLICATION_ACCEPTED: process.env.WECHAT_TEMPLATE_APPLICATION_ACCEPTED?.trim() || null, + SERVICE_ARRANGED: process.env.WECHAT_TEMPLATE_SERVICE_ARRANGED?.trim() || null, + SERVICE_COMPLETED: process.env.WECHAT_TEMPLATE_SERVICE_COMPLETED?.trim() || null, + FOLLOW_UP_PENDING: process.env.WECHAT_TEMPLATE_FOLLOW_UP_PENDING?.trim() || null, + ACTIVITY_REGISTRATION_RESULT: + process.env.WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT?.trim() || null, + } as const; + readonly notificationWorkerEnabled = parseBoolean('NOTIFICATION_WORKER_ENABLED', false); + readonly objectStorageEndpoint = process.env.OBJECT_STORAGE_ENDPOINT?.trim() || null; + readonly objectStorageRegion = process.env.OBJECT_STORAGE_REGION?.trim() || 'auto'; + readonly objectStorageBucket = process.env.OBJECT_STORAGE_BUCKET?.trim() || null; + readonly objectStorageAccessKey = process.env.OBJECT_STORAGE_ACCESS_KEY?.trim() || null; + readonly objectStorageSecretKey = process.env.OBJECT_STORAGE_SECRET_KEY?.trim() || null; + readonly objectStorageForcePathStyle = parseBoolean('OBJECT_STORAGE_FORCE_PATH_STYLE', true); + readonly fieldEncryptionKey = this.parseEncryptionKey(); + readonly database = this.parseDatabaseUrl(); + + get isProduction(): boolean { + return this.appEnvironment === 'production'; + } + + get objectStorageConfigured(): boolean { + return Boolean( + this.objectStorageEndpoint && + this.objectStorageBucket && + this.objectStorageAccessKey && + this.objectStorageSecretKey, + ); + } + get wechatNotificationConfigured(): boolean { + return Boolean( + this.wechatAppId && + this.wechatAppSecret && + Object.values(this.wechatNotificationTemplates).every(Boolean), + ); + } + + private parseEncryptionKey(): Buffer { + const encoded = requiredEnvironmentValue('FIELD_ENCRYPTION_KEY'); + const key = Buffer.from(encoded, 'base64'); + if (key.length !== 32) { + throw new Error('FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key'); + } + return key; + } + + private parseDatabaseUrl(): DatabaseConnectionOptions { + const url = new URL(this.databaseUrl); + if (url.protocol !== 'mysql:') { + throw new Error('DATABASE_URL must use the mysql protocol'); + } + const database = url.pathname.replace(/^\//, ''); + if (!url.hostname || !url.username || !database) { + throw new Error('DATABASE_URL must include host, user, and database'); + } + return { + host: url.hostname, + port: url.port ? Number(url.port) : 3306, + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database, + }; + } +} diff --git a/apps/api/src/content-notifications/content-notifications.module.ts b/apps/api/src/content-notifications/content-notifications.module.ts new file mode 100644 index 0000000..e7e9da3 --- /dev/null +++ b/apps/api/src/content-notifications/content-notifications.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AdminContentController, PublicContentController } from './content.controller'; +import { ContentService } from './content.service'; +import { + AdminNotificationsController, + MeNotificationsController, +} from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { NotificationWorkerService } from './notification-worker.service'; +import { + HttpWechatSubscriptionAdapter, + WechatSubscriptionAdapter, +} from './wechat-subscription.adapter'; +@Module({ + imports: [AuditModule], + controllers: [ + PublicContentController, + AdminContentController, + MeNotificationsController, + AdminNotificationsController, + ], + providers: [ + ContentService, + NotificationsService, + NotificationWorkerService, + { provide: WechatSubscriptionAdapter, useClass: HttpWechatSubscriptionAdapter }, + ], + exports: [NotificationsService], +}) +export class ContentNotificationsModule {} diff --git a/apps/api/src/content-notifications/content.controller.ts b/apps/api/src/content-notifications/content.controller.ts new file mode 100644 index 0000000..f433661 --- /dev/null +++ b/apps/api/src/content-notifications/content.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; +import { + PERMISSION_CODES, + type ContentPostDetailResponse, + type PaginatedContentPostsResponse, +} from '@lehuo/contracts'; +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { Public } from '../common/decorators/public.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { ContentService } from './content.service'; +import { + ContentQueryDto, + CreateContentDto, + PublicContentQueryDto, + TransitionContentDto, + UpdateContentDto, +} from './dto/content-notifications.dto'; +const strict = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); + +@Controller('api/v1/content') +@Public() +export class PublicContentController { + constructor(@Inject(ContentService) private readonly service: ContentService) {} + @Get() list( + @Query(strict(PublicContentQueryDto)) q: PublicContentQueryDto, + ): Promise { + return this.service.listPublic(q); + } + @Get(':publicId') get(@Param('publicId') id: string): Promise { + return this.service.getPublic(id); + } +} + +@Controller('api/v1/admin/content') +@RequireSessionChannel(SessionChannel.ADMIN) +export class AdminContentController { + constructor(@Inject(ContentService) private readonly service: ContentService) {} + @Get() @RequirePermissions(PERMISSION_CODES.CONTENT_READ) list( + @Query(strict(ContentQueryDto)) q: ContentQueryDto, + ) { + return this.service.listAdmin(q); + } + @Get(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_READ) get( + @Param('publicId') id: string, + ) { + return this.service.getAdmin(id); + } + @Post() @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) create( + @Body(strict(CreateContentDto)) input: CreateContentDto, + @CurrentAuth() actor: AuthContext, + @Req() req: RequestContext, + ) { + return this.service.create(input, actor, req); + } + @Patch(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) update( + @Param('publicId') id: string, + @Body(strict(UpdateContentDto)) input: UpdateContentDto, + @CurrentAuth() actor: AuthContext, + @Req() req: RequestContext, + ) { + return this.service.update(id, input, actor, req); + } + @Post(':publicId/transition') @RequirePermissions(PERMISSION_CODES.CONTENT_REVIEW) transition( + @Param('publicId') id: string, + @Body(strict(TransitionContentDto)) input: TransitionContentDto, + @CurrentAuth() actor: AuthContext, + @Req() req: RequestContext, + ) { + return this.service.transition(id, input, actor, req); + } +} diff --git a/apps/api/src/content-notifications/content.service.ts b/apps/api/src/content-notifications/content.service.ts new file mode 100644 index 0000000..ce67479 --- /dev/null +++ b/apps/api/src/content-notifications/content.service.ts @@ -0,0 +1,328 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { + API_ERROR_CODES, + type ContentPostDetail, + type ContentPostDetailResponse, + type PaginatedContentPostsResponse, +} from '@lehuo/contracts'; +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + ContentQueryDto, + CreateContentDto, + PublicContentQueryDto, + TransitionContentDto, + UpdateContentDto, +} from './dto/content-notifications.dto'; + +const include = { + coverFile: true, + statusHistory: { orderBy: { createdAt: 'desc' as const } }, +} satisfies Prisma.ContentPostInclude; +type Stored = Prisma.ContentPostGetPayload<{ include: typeof include }>; +@Injectable() +export class ContentService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + listAdmin(q: ContentQueryDto) { + return this.list( + { + ...(q.type ? { type: q.type as never } : {}), + ...(q.status ? { status: q.status as never } : {}), + }, + q.page, + q.pageSize, + ); + } + listPublic(q: PublicContentQueryDto) { + return this.list( + { status: 'PUBLISHED', ...(q.type ? { type: q.type as never } : {}) }, + q.page, + q.pageSize, + ); + } + async getAdmin(id: string) { + return { post: this.map(await this.find(id)) }; + } + async getPublic(id: string) { + const post = await this.find(id); + if (post.status !== 'PUBLISHED') throw this.notFound(); + return { post: this.map(post) }; + } + async create( + input: CreateContentDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertPlain(input); + const fp = this.fingerprint('content-create', input); + const replay = await this.prisma.contentPost.findUnique({ + where: { + createdById_clientRequestId: { + createdById: actor.userId, + clientRequestId: input.clientRequestId, + }, + }, + include, + }); + if (replay) { + if (replay.requestFingerprint !== fp) throw this.duplicate(); + return { post: this.map(replay), idempotentReplay: true }; + } + const post = await this.prisma.$transaction(async (tx) => { + const p = await tx.contentPost.create({ + data: { + publicId: randomUUID(), + clientRequestId: input.clientRequestId, + requestFingerprint: fp, + type: input.type as never, + title: input.title.trim(), + summary: input.summary.trim(), + content: input.content.trim(), + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await tx.contentPostStatusHistory.create({ + data: { + contentPostId: p.id, + requestId: input.clientRequestId, + requestFingerprint: fp, + toStatus: 'DRAFT', + operatorUserId: actor.userId, + reasonCode: 'CREATED', + }, + }); + await this.audit.record( + request, + { + action: 'CONTENT_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'CONTENT_POST', + resourcePublicId: p.publicId, + metadata: { type: input.type }, + }, + tx, + ); + return tx.contentPost.findUniqueOrThrow({ where: { id: p.id }, include }); + }); + return { post: this.map(post), idempotentReplay: false }; + } + async update(id: string, input: UpdateContentDto, actor: AuthContext, request: RequestContext) { + this.assertPlain(input); + const post = await this.find(id); + if (post.status !== 'DRAFT') throw this.invalid(); + if (post.revision !== input.expectedRevision) throw this.conflict(); + const updated = await this.prisma.$transaction(async (tx) => { + const r = await tx.contentPost.updateMany({ + where: { id: post.id, status: 'DRAFT', revision: input.expectedRevision }, + data: { + type: input.type as never, + title: input.title.trim(), + summary: input.summary.trim(), + content: input.content.trim(), + updatedById: actor.userId, + revision: { increment: 1 }, + privacyReviewedAt: null, + }, + }); + if (r.count !== 1) throw this.conflict(); + await this.audit.record( + request, + { + action: 'CONTENT_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'CONTENT_POST', + resourcePublicId: id, + }, + tx, + ); + return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include }); + }); + return { post: this.map(updated) }; + } + async transition( + id: string, + input: TransitionContentDto, + actor: AuthContext, + request: RequestContext, + ) { + const post = await this.find(id); + const fp = this.fingerprint('content-transition', { + id, + target: input.targetStatus, + note: input.note, + }); + const replay = await this.prisma.contentPostStatusHistory.findUnique({ + where: { requestId: input.clientRequestId }, + include: { contentPost: { include } }, + }); + if (replay) { + if (replay.requestFingerprint !== fp) throw this.duplicate(); + return { post: this.map(replay.contentPost), idempotentReplay: true }; + } + const from = post.status; + const allowed: Record = { + DRAFT: ['PENDING_REVIEW'], + PENDING_REVIEW: ['PUBLISHED', 'DRAFT'], + PUBLISHED: ['ARCHIVED'], + ARCHIVED: [], + }; + if (!allowed[from]?.includes(input.targetStatus)) throw this.invalid(); + if ( + input.targetStatus === 'PUBLISHED' && + post.type === 'SERVICE_STORY' && + input.privacyReviewed !== true + ) + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '服务故事发布前必须确认已完成隐私脱敏审核', + HttpStatus.BAD_REQUEST, + ); + const now = new Date(); + const updated = await this.prisma.$transaction(async (tx) => { + const r = await tx.contentPost.updateMany({ + where: { id: post.id, status: from }, + data: { + status: input.targetStatus as never, + updatedById: actor.userId, + revision: { increment: 1 }, + ...(input.targetStatus === 'PUBLISHED' + ? { + reviewedById: actor.userId, + reviewedAt: now, + publishedAt: now, + privacyReviewedAt: post.type === 'SERVICE_STORY' ? now : post.privacyReviewedAt, + } + : {}), + }, + }); + if (r.count !== 1) throw this.conflict(); + await tx.contentPostStatusHistory.create({ + data: { + contentPostId: post.id, + requestId: input.clientRequestId, + requestFingerprint: fp, + fromStatus: from, + toStatus: input.targetStatus as never, + operatorUserId: actor.userId, + reasonCode: input.targetStatus === 'DRAFT' ? 'REJECTED' : input.targetStatus, + noteEncrypted: this.sensitive.encrypt(input.note.trim()), + }, + }); + await this.audit.record( + request, + { + action: 'CONTENT_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'CONTENT_POST', + resourcePublicId: id, + metadata: { fromStatus: from, toStatus: input.targetStatus }, + }, + tx, + ); + return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include }); + }); + return { post: this.map(updated), idempotentReplay: false }; + } + private async list( + where: Prisma.ContentPostWhereInput, + page: number, + pageSize: number, + ): Promise { + const [total, items] = await this.prisma.$transaction([ + this.prisma.contentPost.count({ where }), + this.prisma.contentPost.findMany({ + where, + include, + orderBy: [{ publishedAt: 'desc' }, { updatedAt: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + return { items: items.map((p) => this.item(p)), page, pageSize, total }; + } + private async find(publicId: string) { + const p = await this.prisma.contentPost.findUnique({ where: { publicId }, include }); + if (!p) throw this.notFound(); + return p; + } + private item(p: Stored) { + return { + publicId: p.publicId, + type: p.type, + title: p.title, + summary: p.summary, + status: p.status, + revision: p.revision, + publishedAt: p.publishedAt?.toISOString() ?? null, + updatedAt: p.updatedAt.toISOString(), + }; + } + private map(p: Stored): ContentPostDetail { + return { + ...this.item(p), + content: p.content, + coverFilePublicId: p.coverFile?.publicId ?? null, + privacyReviewedAt: p.privacyReviewedAt?.toISOString() ?? null, + reviewedAt: p.reviewedAt?.toISOString() ?? null, + history: p.statusHistory.map((h) => ({ + fromStatus: h.fromStatus, + toStatus: h.toStatus, + reasonCode: h.reasonCode, + note: h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null, + createdAt: h.createdAt.toISOString(), + })), + }; + } + private assertPlain(v: { title: string; summary: string; content: string }) { + if (/<\s*script|javascript:|on\w+\s*=/i.test(`${v.title}\n${v.summary}\n${v.content}`)) + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '内容包含不安全标记,请使用纯文本或 Markdown', + HttpStatus.BAD_REQUEST, + ); + } + private fingerprint(d: string, v: object) { + return this.sensitive.lookupHash(d, JSON.stringify(v)); + } + private notFound() { + return new ApplicationError( + API_ERROR_CODES.RESOURCE_NOT_FOUND, + '未找到该内容', + HttpStatus.NOT_FOUND, + ); + } + private invalid() { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + '当前内容状态不能执行该操作', + HttpStatus.CONFLICT, + ); + } + private conflict() { + return new ApplicationError( + API_ERROR_CODES.RESOURCE_CONFLICT, + '内容已更新,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + private duplicate() { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '请求标识已用于其他操作', + HttpStatus.CONFLICT, + ); + } +} diff --git a/apps/api/src/content-notifications/dto/content-notifications.dto.ts b/apps/api/src/content-notifications/dto/content-notifications.dto.ts new file mode 100644 index 0000000..7597d50 --- /dev/null +++ b/apps/api/src/content-notifications/dto/content-notifications.dto.ts @@ -0,0 +1,64 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Length, + Min, + ValidateNested, +} from 'class-validator'; +import { CONTENT_POST_STATUSES, CONTENT_POST_TYPES, NOTIFICATION_TYPES } from '@lehuo/contracts'; +import { PaginationDto } from '../../case-management/dto/case-management.dto'; + +export class ContentQueryDto extends PaginationDto { + @IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string; + @IsOptional() @IsIn(CONTENT_POST_STATUSES) status?: string; +} +export class PublicContentQueryDto extends PaginationDto { + @IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string; +} +export class CreateContentDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(CONTENT_POST_TYPES) type!: string; + @IsString() @Length(2, 200) title!: string; + @IsString() @Length(10, 500) summary!: string; + @IsString() @Length(20, 50000) content!: string; + @IsOptional() @IsUUID('4') coverFilePublicId?: string; +} +export class UpdateContentDto { + @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number; + @IsIn(CONTENT_POST_TYPES) type!: string; + @IsString() @Length(2, 200) title!: string; + @IsString() @Length(10, 500) summary!: string; + @IsString() @Length(20, 50000) content!: string; + @IsOptional() @IsUUID('4') coverFilePublicId?: string; +} +export class TransitionContentDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(CONTENT_POST_STATUSES) targetStatus!: string; + @IsString() @Length(2, 500) note!: string; + @IsOptional() @IsIn([true]) privacyReviewed?: true; +} +export class NotificationQueryDto extends PaginationDto { + @IsOptional() @IsBoolean() @Type(() => Boolean) unreadOnly?: boolean; +} +export class PreferenceInputDto { + @IsIn(NOTIFICATION_TYPES) type!: string; + @IsBoolean() inAppEnabled!: boolean; + @IsBoolean() wechatEnabled!: boolean; +} +export class UpdatePreferencesDto { + @IsArray() + @ArrayMaxSize(NOTIFICATION_TYPES.length) + @ValidateNested({ each: true }) + @Type(() => PreferenceInputDto) + items!: PreferenceInputDto[]; +} +export class DeliveryQueryDto extends PaginationDto { + @IsOptional() @IsIn(['PENDING', 'PROCESSING', 'SENT', 'FAILED', 'DEAD']) status?: string; +} diff --git a/apps/api/src/content-notifications/notification-worker.service.ts b/apps/api/src/content-notifications/notification-worker.service.ts new file mode 100644 index 0000000..0e6f47f --- /dev/null +++ b/apps/api/src/content-notifications/notification-worker.service.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service'; +import { NotificationsService } from './notifications.service'; +@Injectable() +export class NotificationWorkerService implements OnModuleInit, OnModuleDestroy { + private timer?: ReturnType; + constructor( + @Inject(AppConfigService) private readonly config: AppConfigService, + @Inject(NotificationsService) private readonly notifications: NotificationsService, + ) {} + onModuleInit() { + if (!this.config.notificationWorkerEnabled) return; + this.timer = setInterval(() => void this.notifications.processBatch(), 30_000); + this.timer.unref(); + } + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } +} diff --git a/apps/api/src/content-notifications/notifications.controller.ts b/apps/api/src/content-notifications/notifications.controller.ts new file mode 100644 index 0000000..f07e206 --- /dev/null +++ b/apps/api/src/content-notifications/notifications.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + Put, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; +import { PERMISSION_CODES } from '@lehuo/contracts'; +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { + DeliveryQueryDto, + NotificationQueryDto, + UpdatePreferencesDto, +} from './dto/content-notifications.dto'; +import { NotificationsService } from './notifications.service'; +const strict = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); +@Controller('api/v1/me/notifications') +@RequireSessionChannel(SessionChannel.WECHAT) +export class MeNotificationsController { + constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {} + @Get() list( + @Query(strict(NotificationQueryDto)) q: NotificationQueryDto, + @CurrentAuth() a: AuthContext, + ) { + return this.service.list(q, a); + } + @Post(':publicId/read') read( + @Param('publicId') id: string, + @CurrentAuth() a: AuthContext, + @Req() r: RequestContext, + ) { + return this.service.read(id, a, r); + } + @Get('preferences') preferences(@CurrentAuth() a: AuthContext) { + return this.service.preferences(a); + } + @Put('preferences') update( + @Body(strict(UpdatePreferencesDto)) i: UpdatePreferencesDto, + @CurrentAuth() a: AuthContext, + @Req() r: RequestContext, + ) { + return this.service.updatePreferences(i, a, r); + } +} +@Controller('api/v1/admin/notification-deliveries') +@RequireSessionChannel(SessionChannel.ADMIN) +@RequirePermissions(PERMISSION_CODES.NOTIFICATIONS_MANAGE) +export class AdminNotificationsController { + constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {} + @Get() list(@Query(strict(DeliveryQueryDto)) q: DeliveryQueryDto) { + return this.service.deliveries(q); + } + @Post(':publicId/retry') retry( + @Param('publicId') id: string, + @CurrentAuth() a: AuthContext, + @Req() r: RequestContext, + ) { + return this.service.retry(id, a, r); + } + @Post('process') process() { + return this.service.processBatch(); + } +} diff --git a/apps/api/src/content-notifications/notifications.service.ts b/apps/api/src/content-notifications/notifications.service.ts new file mode 100644 index 0000000..5f47baa --- /dev/null +++ b/apps/api/src/content-notifications/notifications.service.ts @@ -0,0 +1,282 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { + API_ERROR_CODES, + NOTIFICATION_TYPES, + type NotificationPreferencesResponse, + type PaginatedNotificationDeliveriesResponse, + type PaginatedNotificationsResponse, +} from '@lehuo/contracts'; +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { AppConfigService } from '../config/app-config.service'; +import { PrismaService } from '../database/prisma.service'; +import type { NotificationType, Prisma } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + DeliveryQueryDto, + NotificationQueryDto, + UpdatePreferencesDto, +} from './dto/content-notifications.dto'; +import { WechatSubscriptionAdapter } from './wechat-subscription.adapter'; + +@Injectable() +export class NotificationsService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppConfigService) private readonly config: AppConfigService, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(WechatSubscriptionAdapter) private readonly wechat: WechatSubscriptionAdapter, + ) {} + async list(q: NotificationQueryDto, actor: AuthContext): Promise { + const where = { userId: actor.userId, ...(q.unreadOnly ? { readAt: null } : {}) }; + const [total, unreadCount, items] = await this.prisma.$transaction([ + this.prisma.notification.count({ where }), + this.prisma.notification.count({ where: { userId: actor.userId, readAt: null } }), + this.prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (q.page - 1) * q.pageSize, + take: q.pageSize, + }), + ]); + return { + items: items.map((n) => ({ + publicId: n.publicId, + type: n.type, + title: n.title, + body: n.body, + resourceType: n.resourceType, + resourcePublicId: n.resourcePublicId, + readAt: n.readAt?.toISOString() ?? null, + createdAt: n.createdAt.toISOString(), + })), + page: q.page, + pageSize: q.pageSize, + total, + unreadCount, + }; + } + async read(id: string, actor: AuthContext, request: RequestContext) { + const n = await this.prisma.notification.findFirst({ + where: { publicId: id, userId: actor.userId }, + }); + if (!n) + throw new ApplicationError( + API_ERROR_CODES.RESOURCE_NOT_FOUND, + '未找到该消息', + HttpStatus.NOT_FOUND, + ); + if (n.readAt) return { read: true, idempotentReplay: true }; + await this.prisma.$transaction(async (tx) => { + await tx.notification.update({ where: { id: n.id }, data: { readAt: new Date() } }); + await this.audit.record( + request, + { + action: 'NOTIFICATION_READ', + result: 'SUCCESS', + actor, + resourceType: 'NOTIFICATION', + resourcePublicId: id, + }, + tx, + ); + }); + return { read: true, idempotentReplay: false }; + } + async preferences(actor: AuthContext): Promise { + const stored = await this.prisma.notificationPreference.findMany({ + where: { userId: actor.userId }, + }); + return { + items: NOTIFICATION_TYPES.map((type) => { + const p = stored.find((v) => v.type === type); + return { + type, + inAppEnabled: p?.inAppEnabled ?? true, + wechatEnabled: p?.wechatEnabled ?? false, + authorizedAt: p?.authorizedAt?.toISOString() ?? null, + }; + }), + wechatAdapterConfigured: this.wechat.configured, + }; + } + async updatePreferences( + input: UpdatePreferencesDto, + actor: AuthContext, + request: RequestContext, + ) { + if (new Set(input.items.map((i) => i.type)).size !== input.items.length) + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '通知类型不能重复', + HttpStatus.BAD_REQUEST, + ); + await this.prisma.$transaction(async (tx) => { + for (const item of input.items) + await tx.notificationPreference.upsert({ + where: { userId_type: { userId: actor.userId, type: item.type as NotificationType } }, + create: { + userId: actor.userId, + type: item.type as NotificationType, + inAppEnabled: item.inAppEnabled, + wechatEnabled: item.wechatEnabled, + authorizedAt: item.wechatEnabled ? new Date() : null, + }, + update: { + inAppEnabled: item.inAppEnabled, + wechatEnabled: item.wechatEnabled, + authorizedAt: item.wechatEnabled ? new Date() : null, + }, + }); + await this.audit.record( + request, + { + action: 'NOTIFICATION_PREFERENCES_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'USER', + resourcePublicId: actor.userPublicId, + metadata: { preferenceCount: input.items.length }, + }, + tx, + ); + }); + return this.preferences(actor); + } + async create( + tx: Prisma.TransactionClient, + input: { + userId: bigint; + eventKey: string; + type: NotificationType; + title: string; + body: string; + resourceType?: string; + resourcePublicId?: string; + }, + ) { + const pref = await tx.notificationPreference.findUnique({ + where: { userId_type: { userId: input.userId, type: input.type } }, + }); + if (pref?.inAppEnabled === false && !pref.wechatEnabled) return null; + const n = await tx.notification.upsert({ + where: { userId_eventKey: { userId: input.userId, eventKey: input.eventKey } }, + create: { publicId: randomUUID(), ...input }, + update: {}, + }); + if (pref?.wechatEnabled) + await tx.notificationDelivery.upsert({ + where: { notificationId_channel: { notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' } }, + create: { publicId: randomUUID(), notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' }, + update: {}, + }); + return n; + } + async deliveries(q: DeliveryQueryDto): Promise { + const where = q.status ? { status: q.status as never } : {}; + const [total, rows] = await this.prisma.$transaction([ + this.prisma.notificationDelivery.count({ where }), + this.prisma.notificationDelivery.findMany({ + where, + include: { notification: true }, + orderBy: { createdAt: 'desc' }, + skip: (q.page - 1) * q.pageSize, + take: q.pageSize, + }), + ]); + return { + items: rows.map((d) => ({ + publicId: d.publicId, + notificationPublicId: d.notification.publicId, + type: d.notification.type, + channel: d.channel, + status: d.status, + attemptCount: d.attemptCount, + nextAttemptAt: d.nextAttemptAt.toISOString(), + lastAttemptAt: d.lastAttemptAt?.toISOString() ?? null, + errorCode: d.errorCode, + createdAt: d.createdAt.toISOString(), + })), + page: q.page, + pageSize: q.pageSize, + total, + }; + } + async retry(id: string, actor: AuthContext, request: RequestContext) { + const d = await this.prisma.notificationDelivery.findUnique({ where: { publicId: id } }); + if (!d) + throw new ApplicationError( + API_ERROR_CODES.RESOURCE_NOT_FOUND, + '未找到该投递任务', + HttpStatus.NOT_FOUND, + ); + await this.prisma.$transaction(async (tx) => { + await tx.notificationDelivery.update({ + where: { id: d.id }, + data: { status: 'PENDING', nextAttemptAt: new Date(), errorCode: null }, + }); + await this.audit.record( + request, + { + action: 'NOTIFICATION_DELIVERY_RETRIED', + result: 'SUCCESS', + actor, + resourceType: 'NOTIFICATION_DELIVERY', + resourcePublicId: id, + }, + tx, + ); + }); + return { queued: true }; + } + async processBatch(limit = 20) { + if (!this.wechat.configured) return { processed: 0, unavailable: true }; + const jobs = await this.prisma.notificationDelivery.findMany({ + where: { status: { in: ['PENDING', 'FAILED'] }, nextAttemptAt: { lte: new Date() } }, + include: { notification: { include: { user: { include: { wechatIdentities: true } } } } }, + orderBy: { nextAttemptAt: 'asc' }, + take: limit, + }); + let processed = 0; + for (const job of jobs) { + const claimed = await this.prisma.notificationDelivery.updateMany({ + where: { id: job.id, status: job.status }, + data: { status: 'PROCESSING', lastAttemptAt: new Date(), attemptCount: { increment: 1 } }, + }); + if (!claimed.count) continue; + processed++; + try { + const identity = job.notification.user.wechatIdentities.find( + (i) => i.appid === this.config.wechatAppId, + ); + if (!identity) throw new Error('WECHAT_IDENTITY_NOT_FOUND'); + const result = await this.wechat.send({ + openid: this.sensitive.decrypt(identity.openidCiphertext), + type: job.notification.type, + title: job.notification.title, + body: job.notification.body, + resourcePublicId: job.notification.resourcePublicId, + }); + await this.prisma.notificationDelivery.update({ + where: { id: job.id }, + data: { status: 'SENT', providerMessageId: result.providerMessageId, errorCode: null }, + }); + } catch (error) { + const attempts = job.attemptCount + 1; + const code = error instanceof Error ? error.message.slice(0, 100) : 'WECHAT_SEND_FAILED'; + await this.prisma.notificationDelivery.update({ + where: { id: job.id }, + data: { + status: attempts >= 5 ? 'DEAD' : 'FAILED', + errorCode: code, + nextAttemptAt: new Date(Date.now() + Math.min(3600, 2 ** attempts * 30) * 1000), + }, + }); + } + } + return { processed, unavailable: false }; + } +} diff --git a/apps/api/src/content-notifications/wechat-subscription.adapter.ts b/apps/api/src/content-notifications/wechat-subscription.adapter.ts new file mode 100644 index 0000000..9b79ddd --- /dev/null +++ b/apps/api/src/content-notifications/wechat-subscription.adapter.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { NotificationType } from '@lehuo/contracts'; +import { AppConfigService } from '../config/app-config.service'; +export interface SubscriptionMessage { + openid: string; + type: NotificationType; + title: string; + body: string; + resourcePublicId: string | null; +} +export interface SubscriptionSendResult { + providerMessageId: string | null; +} +export abstract class WechatSubscriptionAdapter { + abstract get configured(): boolean; + abstract send(message: SubscriptionMessage): Promise; +} +@Injectable() +export class HttpWechatSubscriptionAdapter extends WechatSubscriptionAdapter { + constructor(@Inject(AppConfigService) private readonly config: AppConfigService) { + super(); + } + get configured() { + return this.config.wechatNotificationConfigured; + } + async send(message: SubscriptionMessage) { + if (!this.config.wechatAppId || !this.config.wechatAppSecret) + throw new Error('WECHAT_NOT_CONFIGURED'); + const templateId = this.config.wechatNotificationTemplates[message.type]; + if (!templateId) throw new Error('WECHAT_TEMPLATE_NOT_CONFIGURED'); + const tokenResponse = await fetch( + `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(this.config.wechatAppId)}&secret=${encodeURIComponent(this.config.wechatAppSecret)}`, + ); + const token = (await tokenResponse.json()) as { access_token?: string; errcode?: number }; + if (!tokenResponse.ok || !token.access_token) + throw new Error(`WECHAT_TOKEN_${token.errcode ?? 'FAILED'}`); + const response = await fetch( + `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(token.access_token)}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + touser: message.openid, + template_id: templateId, + page: message.resourcePublicId + ? `pages/index/index?resource=${message.resourcePublicId}` + : 'pages/index/index', + data: { + thing1: { value: message.title.slice(0, 20) }, + thing2: { value: message.body.slice(0, 20) }, + }, + }), + }, + ); + const result = (await response.json()) as { + errcode?: number; + errmsg?: string; + msgid?: string | number; + }; + if (!response.ok || result.errcode !== 0) + throw new Error(`WECHAT_SEND_${result.errcode ?? 'FAILED'}`); + return { providerMessageId: result.msgid?.toString() ?? null }; + } +} diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts new file mode 100644 index 0000000..b87d7a4 --- /dev/null +++ b/apps/api/src/database/database.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class DatabaseModule {} diff --git a/apps/api/src/database/prisma.service.ts b/apps/api/src/database/prisma.service.ts new file mode 100644 index 0000000..3804d89 --- /dev/null +++ b/apps/api/src/database/prisma.service.ts @@ -0,0 +1,26 @@ +import { PrismaMariaDb } from '@prisma/adapter-mariadb'; +import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; + +import { AppConfigService } from '../config/app-config.service'; +import { PrismaClient } from '../generated/prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor(@Inject(AppConfigService) config: AppConfigService) { + const adapter = new PrismaMariaDb({ + ...config.database, + connectionLimit: 10, + connectTimeout: 5_000, + acquireTimeout: 10_000, + }); + super({ adapter }); + } + + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts new file mode 100644 index 0000000..1ccc77a --- /dev/null +++ b/apps/api/src/files/files.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { OBJECT_STORAGE_ADAPTER } from './object-storage.adapter'; +import { FilesRepository } from './files.repository'; +import { S3ObjectStorageAdapter } from './s3-object-storage.adapter'; + +@Module({ + imports: [AuditModule], + providers: [ + S3ObjectStorageAdapter, + FilesRepository, + { provide: OBJECT_STORAGE_ADAPTER, useExisting: S3ObjectStorageAdapter }, + ], + exports: [OBJECT_STORAGE_ADAPTER, FilesRepository], +}) +export class FilesModule {} diff --git a/apps/api/src/files/files.repository.ts b/apps/api/src/files/files.repository.ts new file mode 100644 index 0000000..d25c801 --- /dev/null +++ b/apps/api/src/files/files.repository.ts @@ -0,0 +1,124 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { AuditService } from '../audit/audit.service'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import type { FileBusinessType } from '../generated/prisma/enums'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class FilesRepository { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + findByIdempotency(uploaderUserId: bigint, clientRequestId: string) { + return this.prisma.file.findUnique({ + where: { uploaderUserId_clientRequestId: { uploaderUserId, clientRequestId } }, + }); + } + + findByPublicId(publicId: string) { + return this.prisma.file.findUnique({ where: { publicId } }); + } + + async createPending( + input: { + publicId: string; + caseId?: bigint; + businessType?: FileBusinessType; + clientRequestId: string; + requestFingerprint: string; + provider: string; + bucket: string; + objectKey: string; + filenameEncrypted: string; + mimeType: string; + size: bigint; + sha256?: string; + }, + actor: AuthContext, + request: RequestContext, + ) { + return this.prisma.$transaction(async (transaction) => { + const file = await transaction.file.create({ + data: { + publicId: input.publicId, + caseId: input.caseId, + clientRequestId: input.clientRequestId, + requestFingerprint: input.requestFingerprint, + storageProvider: input.provider, + bucket: input.bucket, + objectKey: input.objectKey, + originalFilenameEncrypted: input.filenameEncrypted, + mimeType: input.mimeType, + size: input.size, + sha256: input.sha256, + businessType: input.businessType ?? 'SERVICE_RECORD', + uploaderUserId: actor.userId, + }, + }); + await this.audit.record( + request, + { + action: 'FILE_UPLOAD_INTENT_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'FILE', + resourcePublicId: file.publicId, + metadata: { + businessType: input.businessType ?? 'SERVICE_RECORD', + mimeType: input.mimeType, + size: Number(input.size), + }, + }, + transaction, + ); + return file; + }); + } + + async markAvailable(publicId: string, actor: AuthContext, request: RequestContext) { + return this.prisma.$transaction(async (transaction) => { + const file = await transaction.file.update({ + where: { publicId }, + data: { status: 'AVAILABLE' }, + }); + await this.audit.record( + request, + { + action: 'FILE_UPLOAD_CONFIRMED', + result: 'SUCCESS', + actor, + resourceType: 'FILE', + resourcePublicId: publicId, + }, + transaction, + ); + return file; + }); + } + + async quarantine( + publicId: string, + actor: AuthContext, + request: RequestContext, + reasonCode: string, + ) { + await this.prisma.$transaction(async (transaction) => { + await transaction.file.update({ where: { publicId }, data: { status: 'QUARANTINED' } }); + await this.audit.record( + request, + { + action: 'FILE_UPLOAD_REJECTED', + result: 'FAILURE', + actor, + resourceType: 'FILE', + resourcePublicId: publicId, + metadata: { reasonCode }, + }, + transaction, + ); + }); + } +} diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts new file mode 100644 index 0000000..34ef74f --- /dev/null +++ b/apps/api/src/files/files.service.ts @@ -0,0 +1,246 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + type ConfirmUploadResponse, + type CreateUploadIntentResponse, + type DownloadUrlResponse, + type ServiceRecordAttachment, +} from '@lehuo/contracts'; + +import { CaseManagementService } from '../case-management/case-management.service'; +import type { CreateUploadIntentDto } from '../case-management/dto/case-management.dto'; +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import type { File } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import { FilesRepository } from './files.repository'; +import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from './object-storage.adapter'; + +const EXTENSIONS: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +@Injectable() +export class FilesService { + constructor( + @Inject(FilesRepository) private readonly repository: FilesRepository, + @Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter, + @Inject(CaseManagementService) private readonly cases: CaseManagementService, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + async createUploadIntent( + casePublicId: string, + input: CreateUploadIntentDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertConfigured(); + const serviceCase = await this.cases.requireAssignedCase(casePublicId, actor); + const filename = this.safeFilename(input.filename); + const extension = EXTENSIONS[input.mimeType]; + if ( + !extension || + (!filename.toLowerCase().endsWith(`.${extension}`) && + !(extension === 'jpg' && filename.toLowerCase().endsWith('.jpeg'))) + ) { + throw this.validation('文件扩展名与图片类型不一致'); + } + const normalized = { + casePublicId, + filename, + mimeType: input.mimeType, + size: input.size, + sha256: input.sha256 ?? null, + }; + const fingerprint = this.sensitiveData.lookupHash( + 'file-upload-intent', + JSON.stringify(normalized), + ); + const previous = await this.repository.findByIdempotency(actor.userId, input.clientRequestId); + if (previous) { + if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest(); + return this.uploadResponse(previous, true); + } + const now = new Date(); + const objectKey = `service-records/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`; + const file = await this.repository.createPending( + { + publicId: randomUUID(), + caseId: serviceCase.id, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + provider: this.storage.providerName, + bucket: this.storage.bucket ?? '', + objectKey, + filenameEncrypted: this.sensitiveData.encrypt(filename), + mimeType: input.mimeType, + size: BigInt(input.size), + sha256: input.sha256, + }, + actor, + request, + ); + return this.uploadResponse(file, false); + } + + async confirm( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertConfigured(); + const file = await this.repository.findByPublicId(publicId); + if (!file) throw this.notFound(); + if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound(); + await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor); + if (file.uploaderUserId !== actor.userId) throw this.notFound(); + if (file.status === 'AVAILABLE') return { file: this.toAttachment(file) }; + if (file.status !== 'UPLOAD_PENDING') { + throw new ApplicationError( + API_ERROR_CODES.UPLOAD_NOT_READY, + '该附件当前不能确认,请重新选择文件', + HttpStatus.CONFLICT, + ); + } + let metadata; + try { + metadata = await this.storage.headObject(file.objectKey); + } catch { + throw this.storageUnavailable(); + } + if (!metadata) { + throw new ApplicationError( + API_ERROR_CODES.UPLOAD_NOT_READY, + '尚未收到上传文件,请完成上传后重试', + HttpStatus.CONFLICT, + ); + } + const mismatch = + metadata.contentLength !== Number(file.size) || + metadata.contentType !== file.mimeType || + (file.sha256 !== null && metadata.sha256 !== file.sha256); + if (mismatch) { + await this.repository.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH'); + throw this.validation('附件校验未通过,请重新选择文件'); + } + return { + file: this.toAttachment(await this.repository.markAvailable(publicId, actor, request)), + }; + } + + async downloadUrl( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertConfigured(); + const file = await this.repository.findByPublicId(publicId); + if (!file || file.status !== 'AVAILABLE') throw this.notFound(); + if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound(); + await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor); + const expiresIn = 300; + let downloadUrl: string; + try { + downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn); + } catch { + throw this.storageUnavailable(); + } + await this.audit.record(request, { + action: 'PRIVATE_FILE_DOWNLOAD_URL_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'FILE', + resourcePublicId: publicId, + }); + return { + downloadUrl, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + }; + } + + private async uploadResponse(file: File, idempotentReplay: boolean) { + const expiresIn = 600; + let uploadUrl: string; + try { + uploadUrl = await this.storage.createUploadUrl( + file.objectKey, + file.mimeType, + file.sha256 ?? undefined, + expiresIn, + ); + } catch { + throw this.storageUnavailable(); + } + return { + file: this.toAttachment(file), + uploadUrl, + uploadHeaders: { + 'Content-Type': file.mimeType, + ...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}), + }, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + idempotentReplay, + } satisfies CreateUploadIntentResponse; + } + + private safeFilename(value: string): string { + const filename = value.trim(); + if ( + !filename || + filename.includes('/') || + filename.includes('\\') || + Array.from(filename).some((character) => character.charCodeAt(0) <= 31) + ) { + throw this.validation('文件名不合法'); + } + return filename; + } + + private toAttachment(file: File): ServiceRecordAttachment { + return { + publicId: file.publicId, + filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted), + mimeType: file.mimeType, + size: Number(file.size), + }; + } + + private assertConfigured(): void { + if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable(); + } + + private storageUnavailable(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.STORAGE_UNAVAILABLE, + '附件服务尚未配置或暂时不可用,请稍后重试', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + + private duplicateRequest(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '本次上传标识已用于其他文件,请重新选择', + HttpStatus.CONFLICT, + ); + } + + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.FILE_NOT_FOUND, + '未找到该附件', + HttpStatus.NOT_FOUND, + ); + } +} diff --git a/apps/api/src/files/object-storage.adapter.ts b/apps/api/src/files/object-storage.adapter.ts new file mode 100644 index 0000000..3c66a31 --- /dev/null +++ b/apps/api/src/files/object-storage.adapter.ts @@ -0,0 +1,21 @@ +export interface StoredObjectMetadata { + contentLength: number; + contentType: string | null; + sha256: string | null; +} + +export interface ObjectStorageAdapter { + readonly configured: boolean; + readonly providerName: string; + readonly bucket: string | null; + createUploadUrl( + objectKey: string, + mimeType: string, + sha256: string | undefined, + expiresInSeconds: number, + ): Promise; + createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise; + headObject(objectKey: string): Promise; +} + +export const OBJECT_STORAGE_ADAPTER = Symbol('OBJECT_STORAGE_ADAPTER'); diff --git a/apps/api/src/files/s3-object-storage.adapter.ts b/apps/api/src/files/s3-object-storage.adapter.ts new file mode 100644 index 0000000..fd232fe --- /dev/null +++ b/apps/api/src/files/s3-object-storage.adapter.ts @@ -0,0 +1,102 @@ +import { + GetObjectCommand, + HeadObjectCommand, + NotFound, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Inject, Injectable } from '@nestjs/common'; + +import { AppConfigService } from '../config/app-config.service'; +import type { ObjectStorageAdapter, StoredObjectMetadata } from './object-storage.adapter'; + +@Injectable() +export class S3ObjectStorageAdapter implements ObjectStorageAdapter { + readonly providerName = 'S3_COMPATIBLE'; + private readonly client: S3Client | null; + + constructor(@Inject(AppConfigService) private readonly config: AppConfigService) { + this.client = config.objectStorageConfigured + ? new S3Client({ + endpoint: config.objectStorageEndpoint ?? undefined, + region: config.objectStorageRegion, + forcePathStyle: config.objectStorageForcePathStyle, + credentials: { + accessKeyId: config.objectStorageAccessKey ?? '', + secretAccessKey: config.objectStorageSecretKey ?? '', + }, + }) + : null; + } + + get configured(): boolean { + return this.client !== null; + } + + get bucket(): string | null { + return this.config.objectStorageBucket; + } + + createUploadUrl( + objectKey: string, + mimeType: string, + sha256: string | undefined, + expiresInSeconds: number, + ): Promise { + return getSignedUrl( + this.requiredClient(), + new PutObjectCommand({ + Bucket: this.requiredBucket(), + Key: objectKey, + ContentType: mimeType, + Metadata: sha256 ? { sha256 } : undefined, + }), + { expiresIn: expiresInSeconds }, + ); + } + + createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise { + return getSignedUrl( + this.requiredClient(), + new GetObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }), + { expiresIn: expiresInSeconds }, + ); + } + + async headObject(objectKey: string): Promise { + try { + const result = await this.requiredClient().send( + new HeadObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }), + ); + return { + contentLength: result.ContentLength ?? 0, + contentType: result.ContentType ?? null, + sha256: result.Metadata?.sha256 ?? null, + }; + } catch (error) { + if ( + error instanceof NotFound || + (this.isMetadataError(error) && error.$metadata.httpStatusCode === 404) + ) { + return null; + } + throw error; + } + } + + private requiredClient(): S3Client { + if (!this.client) throw new Error('Object storage is not configured'); + return this.client; + } + + private requiredBucket(): string { + const bucket = this.config.objectStorageBucket; + if (!bucket) throw new Error('Object storage bucket is not configured'); + return bucket; + } + + private isMetadataError(error: unknown): error is { $metadata: { httpStatusCode?: number } } { + return typeof error === 'object' && error !== null && '$metadata' in error; + } +} diff --git a/apps/api/src/follow-ups/admin-follow-ups.controller.ts b/apps/api/src/follow-ups/admin-follow-ups.controller.ts new file mode 100644 index 0000000..710cb40 --- /dev/null +++ b/apps/api/src/follow-ups/admin-follow-ups.controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import { + PERMISSION_CODES, + type CompleteServiceCaseResponse, + type FollowUpDetailResponse, + type FollowUpMutationResponse, + type FollowUpWorkbenchSummary, + type PaginatedFollowUpsResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { + CompleteFollowUpDto, + CompleteServiceCaseDto, + CreateFollowUpDto, + FollowUpQueryDto, +} from './dto/follow-up.dto'; +import { FollowUpsService } from './follow-ups.service'; + +const strictValidation = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); + +@Controller('api/v1/admin') +@RequireSessionChannel(SessionChannel.ADMIN) +export class AdminFollowUpsController { + constructor(@Inject(FollowUpsService) private readonly followUps: FollowUpsService) {} + + @Post('service-cases/:publicId/complete') + @RequirePermissions(PERMISSION_CODES.CASES_MANAGE, PERMISSION_CODES.FOLLOW_UPS_MANAGE) + completeCase( + @Param('publicId') publicId: string, + @Body(strictValidation(CompleteServiceCaseDto)) input: CompleteServiceCaseDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.followUps.completeCase(publicId, input, actor, request); + } + + @Get('follow-ups/summary') + @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ) + summary(@CurrentAuth() actor: AuthContext): Promise { + return this.followUps.summary(actor); + } + + @Get('follow-ups') + @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ) + list( + @Query(strictValidation(FollowUpQueryDto)) query: FollowUpQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.followUps.list(query, actor); + } + + @Post('follow-ups') + @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_MANAGE) + create( + @Body(strictValidation(CreateFollowUpDto)) input: CreateFollowUpDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.followUps.create(input, actor, request); + } + + @Get('follow-ups/:publicId') + @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ) + get( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.followUps.get(publicId, actor); + } + + @Post('follow-ups/:publicId/complete') + @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_COMPLETE) + complete( + @Param('publicId') publicId: string, + @Body(strictValidation(CompleteFollowUpDto)) input: CompleteFollowUpDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.followUps.complete(publicId, input, actor, request); + } +} diff --git a/apps/api/src/follow-ups/dto/follow-up.dto.ts b/apps/api/src/follow-ups/dto/follow-up.dto.ts new file mode 100644 index 0000000..ec3d548 --- /dev/null +++ b/apps/api/src/follow-ups/dto/follow-up.dto.ts @@ -0,0 +1,113 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +import { FOLLOW_UP_RESULT_CODES, FOLLOW_UP_STATUSES, FOLLOW_UP_TYPES } from '@lehuo/contracts'; + +export class CompleteServiceCaseDto { + @IsUUID('4') + clientRequestId!: string; + + @IsString() + @MinLength(2) + @MaxLength(5000) + resultSummary!: string; + + @IsString() + @MinLength(2) + @MaxLength(2000) + followUpPlan!: string; + + @IsISO8601({ strict: true }) + followUpDueAt!: string; +} + +export class CreateFollowUpDto { + @IsUUID('4') + clientRequestId!: string; + + @IsUUID('4') + beneficiaryPublicId!: string; + + @IsUUID('4') + ownerStaffPublicId!: string; + + @IsOptional() + @IsUUID('4') + serviceCasePublicId?: string; + + @IsIn(FOLLOW_UP_TYPES.filter((value) => value !== 'SERVICE_COMPLETION')) + followUpType!: 'KEY_CARE' | 'MANUAL'; + + @IsISO8601({ strict: true }) + dueAt!: string; + + @IsString() + @MinLength(2) + @MaxLength(2000) + reason!: string; +} + +export class CompleteFollowUpDto { + @IsUUID('4') + clientRequestId!: string; + + @IsIn(FOLLOW_UP_RESULT_CODES) + resultCode!: (typeof FOLLOW_UP_RESULT_CODES)[number]; + + @IsString() + @MinLength(2) + @MaxLength(5000) + resultNote!: string; + + @IsOptional() + @IsIn([true]) + continuationConsentConfirmed?: true; + + @IsOptional() + @IsString() + @MinLength(10) + @MaxLength(5000) + continuationDescription?: string; +} + +export class FollowUpQueryDto { + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 20; + + @IsOptional() + @IsIn(FOLLOW_UP_STATUSES) + status?: (typeof FOLLOW_UP_STATUSES)[number]; + + @IsOptional() + @IsIn(FOLLOW_UP_TYPES) + followUpType?: (typeof FOLLOW_UP_TYPES)[number]; + + @IsOptional() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value as unknown; + }) + @IsBoolean() + overdue?: boolean; +} diff --git a/apps/api/src/follow-ups/follow-ups.module.ts b/apps/api/src/follow-ups/follow-ups.module.ts new file mode 100644 index 0000000..960f16e --- /dev/null +++ b/apps/api/src/follow-ups/follow-ups.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { ContentNotificationsModule } from '../content-notifications/content-notifications.module'; +import { AdminFollowUpsController } from './admin-follow-ups.controller'; +import { FollowUpsRepository } from './follow-ups.repository'; +import { FollowUpsService } from './follow-ups.service'; + +@Module({ + imports: [AuditModule, ContentNotificationsModule], + controllers: [AdminFollowUpsController], + providers: [FollowUpsRepository, FollowUpsService], +}) +export class FollowUpsModule {} diff --git a/apps/api/src/follow-ups/follow-ups.repository.ts b/apps/api/src/follow-ups/follow-ups.repository.ts new file mode 100644 index 0000000..4dc5273 --- /dev/null +++ b/apps/api/src/follow-ups/follow-ups.repository.ts @@ -0,0 +1,466 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { FollowUpResultCode, FollowUpStatus, FollowUpType } from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { NotificationsService } from '../content-notifications/notifications.service'; + +const followUpInclude = { + beneficiary: true, + serviceCase: { include: { application: true } }, + owner: { include: { staffProfile: true } }, + continuedApplication: true, + statusHistory: { orderBy: { createdAt: 'asc' as const } }, +} satisfies Prisma.FollowUpTaskInclude; + +export type StoredFollowUp = Prisma.FollowUpTaskGetPayload<{ include: typeof followUpInclude }>; + +export interface CompleteCaseInput { + requestId: string; + fingerprint: string; + resultSummaryEncrypted: string; + followUpPlanEncrypted: string; + followUpDueAt: Date; + followUpPublicId: string; + followUpNo: string; + followUpReasonEncrypted: string; +} + +export interface CreateFollowUpInput { + requestId: string; + fingerprint: string; + publicId: string; + followUpNo: string; + beneficiaryId: bigint; + caseId?: bigint; + ownerStaffId: bigint; + dueAt: Date; + followUpType: 'KEY_CARE' | 'MANUAL'; + reasonEncrypted: string; +} + +export interface CompleteFollowUpInput { + requestId: string; + fingerprint: string; + resultCode: FollowUpResultCode; + resultNoteEncrypted: string; + continuation?: { + publicId: string; + applicationNo: string; + requestFingerprint: string; + consentPublicId: string; + descriptionEncrypted: string; + }; +} + +@Injectable() +export class FollowUpsRepository { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(NotificationsService) private readonly notifications: NotificationsService, + ) {} + + findCase(publicId: string) { + return this.prisma.serviceCase.findUnique({ + where: { publicId }, + include: { + application: true, + beneficiary: true, + owner: { include: { staffProfile: true } }, + _count: { select: { serviceRecords: true } }, + followUpTasks: { where: { source: 'AUTO_SERVICE_COMPLETION' }, include: followUpInclude }, + }, + }); + } + + findBeneficiary(publicId: string) { + return this.prisma.beneficiaryProfile.findUnique({ where: { publicId } }); + } + + findStaff(publicId: string) { + return this.prisma.user.findFirst({ + where: { publicId, status: 'ACTIVE', userType: 'STAFF', staffProfile: { isNot: null } }, + include: { staffProfile: true }, + }); + } + + findByCreateRequest(createdById: bigint, requestId: string) { + return this.prisma.followUpTask.findUnique({ + where: { createdById_createRequestId: { createdById, createRequestId: requestId } }, + include: followUpInclude, + }); + } + + find(publicId: string) { + return this.prisma.followUpTask.findUnique({ where: { publicId }, include: followUpInclude }); + } + + async list( + filters: { status?: FollowUpStatus; followUpType?: FollowUpType; overdue?: boolean }, + actorUserId: bigint, + accessAll: boolean, + page: number, + pageSize: number, + now: Date, + ) { + const where: Prisma.FollowUpTaskWhereInput = { + ...(accessAll ? {} : { ownerStaffId: actorUserId }), + ...(filters.status ? { status: filters.status } : {}), + ...(filters.followUpType ? { followUpType: filters.followUpType } : {}), + ...(filters.overdue === true ? { status: 'PENDING', dueAt: { lt: now } } : {}), + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.followUpTask.findMany({ + where, + include: followUpInclude, + orderBy: [{ dueAt: 'asc' }, { id: 'asc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.followUpTask.count({ where }), + ]); + return { items, total }; + } + + async summary( + actorUserId: bigint, + accessAll: boolean, + start: Date, + tomorrow: Date, + now: Date, + weekEnd: Date, + ) { + const base: Prisma.FollowUpTaskWhereInput = { + status: 'PENDING', + ...(accessAll ? {} : { ownerStaffId: actorUserId }), + }; + const [dueToday, overdue, dueThisWeek, keyCarePending] = await this.prisma.$transaction([ + this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: start, lt: tomorrow } } }), + this.prisma.followUpTask.count({ where: { ...base, dueAt: { lt: now } } }), + this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: now, lt: weekEnd } } }), + this.prisma.followUpTask.count({ + where: { ...base, beneficiary: { keyCareFlag: true } }, + }), + ]); + return { dueToday, overdue, dueThisWeek, keyCarePending }; + } + + async completeCase( + serviceCase: NonNullable>>, + input: CompleteCaseInput, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const current = await transaction.serviceCase.findUniqueOrThrow({ + where: { id: serviceCase.id }, + include: { _count: { select: { serviceRecords: true } } }, + }); + if (current.status !== 'IN_SERVICE' || current.ownerStaffId !== actor.userId) + throw new Error('CASE_COMPLETE_STATE_CONFLICT'); + if (current._count.serviceRecords < 1) throw new Error('CASE_REQUIRES_RECORD'); + + await transaction.serviceCase.update({ + where: { id: current.id }, + data: { + status: 'SERVICE_COMPLETED', + resultSummaryEncrypted: input.resultSummaryEncrypted, + followUpPlanEncrypted: input.followUpPlanEncrypted, + completedAt: new Date(), + completeRequestId: input.requestId, + completeRequestFingerprint: input.fingerprint, + updatedById: actor.userId, + }, + }); + await transaction.serviceCaseStatusHistory.create({ + data: { + caseId: current.id, + fromStatus: 'IN_SERVICE', + toStatus: 'SERVICE_COMPLETED', + operatorUserId: actor.userId, + reasonCode: 'SERVICE_COMPLETED', + }, + }); + const applicationCompleted = await transaction.serviceApplication.updateMany({ + where: { id: current.applicationId, status: 'IN_SERVICE' }, + data: { status: 'SERVICE_COMPLETED' }, + }); + if (applicationCompleted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT'); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: current.applicationId, + fromStatus: 'IN_SERVICE', + toStatus: 'SERVICE_COMPLETED', + operatorUserId: actor.userId, + reasonCode: 'SERVICE_COMPLETED', + }, + }); + await transaction.serviceApplication.update({ + where: { id: current.applicationId }, + data: { status: 'FOLLOW_UP_PENDING' }, + }); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: current.applicationId, + fromStatus: 'SERVICE_COMPLETED', + toStatus: 'FOLLOW_UP_PENDING', + operatorUserId: actor.userId, + reasonCode: 'FOLLOW_UP_CREATED', + }, + }); + const application = await transaction.serviceApplication.findUniqueOrThrow({ + where: { id: current.applicationId }, + }); + await this.notifications.create(transaction, { + userId: application.applicantUserId, + eventKey: `application:${application.publicId}:completed`, + type: 'SERVICE_COMPLETED', + title: '本次服务已完成', + body: '本次服务已记录完成,感谢您的配合。', + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: application.publicId, + }); + await this.notifications.create(transaction, { + userId: application.applicantUserId, + eventKey: `application:${application.publicId}:follow-up`, + type: 'FOLLOW_UP_PENDING', + title: '后续将进行服务回访', + body: '工作人员将在约定时间与您回访服务情况。', + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: application.publicId, + }); + const followUp = await transaction.followUpTask.create({ + data: { + publicId: input.followUpPublicId, + followUpNo: input.followUpNo, + beneficiaryId: current.beneficiaryId, + caseId: current.id, + ownerStaffId: current.ownerStaffId, + dueAt: input.followUpDueAt, + followUpType: 'SERVICE_COMPLETION', + source: 'AUTO_SERVICE_COMPLETION', + reasonEncrypted: input.followUpReasonEncrypted, + createRequestId: input.requestId, + createRequestFingerprint: input.fingerprint, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await transaction.followUpStatusHistory.create({ + data: { + followUpTaskId: followUp.id, + toStatus: 'PENDING', + operatorUserId: actor.userId, + reasonCode: 'AUTO_CREATED_AFTER_SERVICE', + }, + }); + await this.audit.record( + request, + { + action: 'SERVICE_CASE_COMPLETED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_CASE', + resourcePublicId: serviceCase.publicId, + metadata: { followUpPublicId: input.followUpPublicId }, + }, + transaction, + ); + await this.audit.record( + request, + { + action: 'FOLLOW_UP_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'FOLLOW_UP_TASK', + resourcePublicId: input.followUpPublicId, + metadata: { source: 'AUTO_SERVICE_COMPLETION' }, + }, + transaction, + ); + return transaction.followUpTask.findUniqueOrThrow({ + where: { id: followUp.id }, + include: followUpInclude, + }); + }); + } + + async create( + input: CreateFollowUpInput, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const followUp = await transaction.followUpTask.create({ + data: { + publicId: input.publicId, + followUpNo: input.followUpNo, + beneficiaryId: input.beneficiaryId, + caseId: input.caseId, + ownerStaffId: input.ownerStaffId, + dueAt: input.dueAt, + followUpType: input.followUpType, + source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL', + reasonEncrypted: input.reasonEncrypted, + createRequestId: input.requestId, + createRequestFingerprint: input.fingerprint, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await transaction.followUpStatusHistory.create({ + data: { + followUpTaskId: followUp.id, + toStatus: 'PENDING', + operatorUserId: actor.userId, + reasonCode: 'MANUAL_CREATED', + }, + }); + await this.audit.record( + request, + { + action: 'FOLLOW_UP_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'FOLLOW_UP_TASK', + resourcePublicId: input.publicId, + metadata: { + source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL', + }, + }, + transaction, + ); + return transaction.followUpTask.findUniqueOrThrow({ + where: { id: followUp.id }, + include: followUpInclude, + }); + }); + } + + async complete( + followUp: StoredFollowUp, + input: CompleteFollowUpInput, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const changed = await transaction.followUpTask.updateMany({ + where: { id: followUp.id, status: 'PENDING', ownerStaffId: actor.userId }, + data: { + status: 'COMPLETED', + resultCode: input.resultCode, + resultNoteEncrypted: input.resultNoteEncrypted, + completeRequestId: input.requestId, + completeRequestFingerprint: input.fingerprint, + completedAt: new Date(), + updatedById: actor.userId, + }, + }); + if (changed.count !== 1) throw new Error('FOLLOW_UP_STATE_CONFLICT'); + await transaction.followUpStatusHistory.create({ + data: { + followUpTaskId: followUp.id, + fromStatus: 'PENDING', + toStatus: 'COMPLETED', + operatorUserId: actor.userId, + reasonCode: input.resultCode, + }, + }); + + const parent = followUp.serviceCase?.application; + if (input.continuation) { + if (!parent) throw new Error('CONTINUATION_REQUIRES_APPLICATION'); + const beneficiary = await transaction.beneficiaryProfile.findUniqueOrThrow({ + where: { id: followUp.beneficiaryId }, + }); + const consent = await transaction.consent.create({ + data: { + publicId: input.continuation.consentPublicId, + userId: parent.applicantUserId, + beneficiaryId: followUp.beneficiaryId, + consentType: 'SERVICE_APPLICATION', + version: 'service-continuation-v1', + grantedBy: beneficiary.isMinor ? 'GUARDIAN' : 'APPLICANT', + grantedAt: new Date(), + evidenceJson: { + source: 'ADMIN_FOLLOW_UP', + confirmationMethod: 'EXPLICIT_CONFIRMATION', + followUpPublicId: followUp.publicId, + }, + }, + }); + const continued = await transaction.serviceApplication.create({ + data: { + publicId: input.continuation.publicId, + applicationNo: input.continuation.applicationNo, + applicantUserId: parent.applicantUserId, + consentId: consent.id, + clientRequestId: input.requestId, + requestFingerprint: input.continuation.requestFingerprint, + applyMode: parent.applyMode, + serviceCategory: parent.serviceCategory, + beneficiaryNameEncrypted: parent.beneficiaryNameEncrypted, + contactMobileEncrypted: parent.contactMobileEncrypted, + contactMobileHash: parent.contactMobileHash, + regionCode: parent.regionCode, + descriptionEncrypted: input.continuation.descriptionEncrypted, + preferredContactTime: parent.preferredContactTime, + needsHomeVisit: parent.needsHomeVisit, + mobilityDifficulty: parent.mobilityDifficulty, + source: 'FOLLOW_UP', + beneficiaryId: followUp.beneficiaryId, + sourceFollowUpTaskId: followUp.id, + parentApplicationId: parent.id, + submittedAt: new Date(), + }, + }); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: continued.id, + toStatus: 'SUBMITTED', + operatorUserId: actor.userId, + reasonCode: 'FOLLOW_UP_CONTINUATION_CREATED', + }, + }); + } + if (parent?.status === 'FOLLOW_UP_PENDING') { + await transaction.serviceApplication.update({ + where: { id: parent.id }, + data: { status: 'CLOSED', closedAt: new Date() }, + }); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: parent.id, + fromStatus: 'FOLLOW_UP_PENDING', + toStatus: 'CLOSED', + operatorUserId: actor.userId, + reasonCode: input.continuation ? 'CONTINUATION_CREATED' : 'FOLLOW_UP_COMPLETED', + }, + }); + } + await this.audit.record( + request, + { + action: 'FOLLOW_UP_COMPLETED', + result: 'SUCCESS', + actor, + resourceType: 'FOLLOW_UP_TASK', + resourcePublicId: followUp.publicId, + metadata: { + resultCode: input.resultCode, + continuationCreated: Boolean(input.continuation), + }, + }, + transaction, + ); + return transaction.followUpTask.findUniqueOrThrow({ + where: { id: followUp.id }, + include: followUpInclude, + }); + }); + } +} diff --git a/apps/api/src/follow-ups/follow-ups.service.ts b/apps/api/src/follow-ups/follow-ups.service.ts new file mode 100644 index 0000000..5cab8e5 --- /dev/null +++ b/apps/api/src/follow-ups/follow-ups.service.ts @@ -0,0 +1,427 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + FOLLOW_UP_RESULT_LABELS, + FOLLOW_UP_STATUS_LABELS, + FOLLOW_UP_TYPE_LABELS, + PERMISSION_CODES, + type CompleteServiceCaseResponse, + type FollowUpDetail, + type FollowUpDetailResponse, + type FollowUpListItem, + type FollowUpMutationResponse, + type FollowUpWorkbenchSummary, + type PaginatedFollowUpsResponse, +} from '@lehuo/contracts'; + +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + CompleteFollowUpDto, + CompleteServiceCaseDto, + CreateFollowUpDto, + FollowUpQueryDto, +} from './dto/follow-up.dto'; +import { FollowUpsRepository, type StoredFollowUp } from './follow-ups.repository'; + +function shanghaiDatePrefix(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}${value.month}${value.day}`; +} + +function shanghaiDayStart(now: Date): Date { + const date = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + return new Date(`${date}T00:00:00+08:00`); +} + +function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +@Injectable() +export class FollowUpsService { + constructor( + @Inject(FollowUpsRepository) private readonly repository: FollowUpsRepository, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + ) {} + + async completeCase( + publicId: string, + input: CompleteServiceCaseDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const normalized = { + resultSummary: input.resultSummary.trim(), + followUpPlan: input.followUpPlan.trim(), + followUpDueAt: new Date(input.followUpDueAt).toISOString(), + }; + const fingerprint = this.sensitiveData.lookupHash( + 'service-case-completion', + JSON.stringify(normalized), + ); + const serviceCase = await this.repository.findCase(publicId); + if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件'); + if (serviceCase.completeRequestId) { + if ( + serviceCase.completeRequestId !== input.clientRequestId || + serviceCase.completeRequestFingerprint !== fingerprint || + !serviceCase.followUpTasks[0] + ) { + throw this.duplicateRequest(); + } + return this.caseCompletionResponse(serviceCase.followUpTasks[0], true); + } + if (serviceCase.status !== 'IN_SERVICE' || serviceCase.ownerStaffId !== actor.userId) { + throw this.invalidTransition(); + } + if (serviceCase._count.serviceRecords < 1) { + throw this.validation('至少记录一次实际服务后才能完成案件'); + } + const dueAt = new Date(normalized.followUpDueAt); + if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间'); + const now = new Date(); + try { + const followUp = await this.repository.completeCase( + serviceCase, + { + requestId: input.clientRequestId, + fingerprint, + resultSummaryEncrypted: this.sensitiveData.encrypt(normalized.resultSummary), + followUpPlanEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan), + followUpDueAt: dueAt, + followUpPublicId: randomUUID(), + followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`, + followUpReasonEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan), + }, + actor, + request, + ); + return this.caseCompletionResponse(followUp, false); + } catch (error) { + if (isUniqueConstraintError(error)) { + const replay = await this.repository.findCase(publicId); + if ( + replay?.completeRequestId === input.clientRequestId && + replay.completeRequestFingerprint === fingerprint && + replay.followUpTasks[0] + ) { + return this.caseCompletionResponse(replay.followUpTasks[0], true); + } + throw this.duplicateRequest(); + } + if (error instanceof Error && error.message === 'CASE_REQUIRES_RECORD') { + throw this.validation('至少记录一次实际服务后才能完成案件'); + } + if ( + error instanceof Error && + ['CASE_COMPLETE_STATE_CONFLICT', 'APPLICATION_STATE_CONFLICT'].includes(error.message) + ) { + throw this.invalidTransition(); + } + throw error; + } + } + + async create( + input: CreateFollowUpDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const normalized = { + beneficiaryPublicId: input.beneficiaryPublicId, + ownerStaffPublicId: input.ownerStaffPublicId, + serviceCasePublicId: input.serviceCasePublicId ?? null, + followUpType: input.followUpType, + dueAt: new Date(input.dueAt).toISOString(), + reason: input.reason.trim(), + }; + const fingerprint = this.sensitiveData.lookupHash( + 'follow-up-create', + JSON.stringify(normalized), + ); + const replay = await this.repository.findByCreateRequest(actor.userId, input.clientRequestId); + if (replay) { + if (replay.createRequestFingerprint !== fingerprint) throw this.duplicateRequest(); + return { followUp: this.toDetail(replay), idempotentReplay: true }; + } + const [beneficiary, owner, serviceCase] = await Promise.all([ + this.repository.findBeneficiary(input.beneficiaryPublicId), + this.repository.findStaff(input.ownerStaffPublicId), + input.serviceCasePublicId + ? this.repository.findCase(input.serviceCasePublicId) + : Promise.resolve(null), + ]); + if (!beneficiary) + throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案'); + if (!owner) throw this.validation('回访负责人必须是有效工作人员'); + if (input.serviceCasePublicId && !serviceCase) + throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件'); + if (serviceCase && serviceCase.beneficiaryId !== beneficiary.id) { + throw this.validation('服务案件与回访对象不一致'); + } + if (input.followUpType === 'KEY_CARE' && !beneficiary.keyCareFlag) { + throw this.validation('只有重点关爱对象可以创建重点关爱回访'); + } + const dueAt = new Date(normalized.dueAt); + if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间'); + const now = new Date(); + try { + const created = await this.repository.create( + { + requestId: input.clientRequestId, + fingerprint, + publicId: randomUUID(), + followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`, + beneficiaryId: beneficiary.id, + caseId: serviceCase?.id, + ownerStaffId: owner.id, + dueAt, + followUpType: input.followUpType, + reasonEncrypted: this.sensitiveData.encrypt(normalized.reason), + }, + actor, + request, + ); + return { followUp: this.toDetail(created), idempotentReplay: false }; + } catch (error) { + if (isUniqueConstraintError(error)) { + const existing = await this.repository.findByCreateRequest( + actor.userId, + input.clientRequestId, + ); + if (existing?.createRequestFingerprint === fingerprint) { + return { followUp: this.toDetail(existing), idempotentReplay: true }; + } + throw this.duplicateRequest(); + } + throw error; + } + } + + async list(query: FollowUpQueryDto, actor: AuthContext): Promise { + const result = await this.repository.list( + query, + actor.userId, + actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL), + query.page, + query.pageSize, + new Date(), + ); + return { + items: result.items.map((item) => this.toListItem(item)), + page: query.page, + pageSize: query.pageSize, + total: result.total, + }; + } + + async summary(actor: AuthContext): Promise { + const now = new Date(); + const start = shanghaiDayStart(now); + const tomorrow = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + return this.repository.summary( + actor.userId, + actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL), + start, + tomorrow, + now, + weekEnd, + ); + } + + async get(publicId: string, actor: AuthContext): Promise { + const followUp = await this.repository.find(publicId); + if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务'); + this.assertVisible(followUp, actor); + return { followUp: this.toDetail(followUp) }; + } + + async complete( + publicId: string, + input: CompleteFollowUpDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const normalized = { + resultCode: input.resultCode, + resultNote: input.resultNote.trim(), + continuationConsentConfirmed: input.continuationConsentConfirmed === true, + continuationDescription: input.continuationDescription?.trim() ?? null, + }; + if (input.resultCode === 'NEED_MORE_HELP') { + if (input.continuationConsentConfirmed !== true || !normalized.continuationDescription) { + throw this.validation('继续帮扶必须确认服务对象或监护人授权,并填写新的帮扶需求'); + } + } else if (input.continuationConsentConfirmed || input.continuationDescription) { + throw this.validation('只有“需要继续帮扶”才能创建关联申请'); + } + const fingerprint = this.sensitiveData.lookupHash( + 'follow-up-completion', + JSON.stringify(normalized), + ); + const followUp = await this.repository.find(publicId); + if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务'); + if (followUp.completeRequestId) { + if ( + followUp.completeRequestId !== input.clientRequestId || + followUp.completeRequestFingerprint !== fingerprint + ) { + throw this.duplicateRequest(); + } + return { followUp: this.toDetail(followUp), idempotentReplay: true }; + } + if (followUp.status !== 'PENDING' || followUp.ownerStaffId !== actor.userId) { + throw this.invalidTransition(); + } + if (input.resultCode === 'NEED_MORE_HELP' && !followUp.serviceCase?.application) { + throw this.validation('该回访没有可关联的原服务申请,不能创建继续帮扶申请'); + } + const now = new Date(); + try { + const completed = await this.repository.complete( + followUp, + { + requestId: input.clientRequestId, + fingerprint, + resultCode: input.resultCode, + resultNoteEncrypted: this.sensitiveData.encrypt(normalized.resultNote), + continuation: + input.resultCode === 'NEED_MORE_HELP' && normalized.continuationDescription + ? { + publicId: randomUUID(), + applicationNo: `SQ${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`, + requestFingerprint: this.sensitiveData.lookupHash( + 'service-continuation', + JSON.stringify({ followUpPublicId: publicId, ...normalized }), + ), + consentPublicId: randomUUID(), + descriptionEncrypted: this.sensitiveData.encrypt( + normalized.continuationDescription, + ), + } + : undefined, + }, + actor, + request, + ); + return { followUp: this.toDetail(completed), idempotentReplay: false }; + } catch (error) { + if (isUniqueConstraintError(error)) { + const replay = await this.repository.find(publicId); + if ( + replay?.completeRequestId === input.clientRequestId && + replay.completeRequestFingerprint === fingerprint + ) { + return { followUp: this.toDetail(replay), idempotentReplay: true }; + } + throw this.duplicateRequest(); + } + if (error instanceof Error && error.message === 'FOLLOW_UP_STATE_CONFLICT') { + throw this.invalidTransition(); + } + throw error; + } + } + + private caseCompletionResponse( + followUp: StoredFollowUp, + idempotentReplay: boolean, + ): CompleteServiceCaseResponse { + return { + serviceCasePublicId: followUp.serviceCase?.publicId ?? '', + serviceCaseStatus: 'SERVICE_COMPLETED', + applicationStatus: 'FOLLOW_UP_PENDING', + followUp: this.toListItem(followUp), + idempotentReplay, + }; + } + + private toListItem(followUp: StoredFollowUp): FollowUpListItem { + return { + publicId: followUp.publicId, + followUpNo: followUp.followUpNo, + beneficiaryPublicId: followUp.beneficiary.publicId, + beneficiaryDisplayName: this.sensitiveData.decrypt(followUp.beneficiary.displayNameEncrypted), + beneficiaryKeyCareFlag: followUp.beneficiary.keyCareFlag, + category: followUp.beneficiary.primaryCategory, + serviceCasePublicId: followUp.serviceCase?.publicId ?? null, + serviceCaseNo: followUp.serviceCase?.caseNo ?? null, + ownerStaffPublicId: followUp.owner.publicId, + ownerDisplayName: followUp.owner.staffProfile?.displayName ?? '工作人员', + dueAt: followUp.dueAt.toISOString(), + followUpType: followUp.followUpType, + followUpTypeLabel: FOLLOW_UP_TYPE_LABELS[followUp.followUpType], + status: followUp.status, + statusLabel: FOLLOW_UP_STATUS_LABELS[followUp.status], + resultCode: followUp.resultCode, + resultLabel: followUp.resultCode ? FOLLOW_UP_RESULT_LABELS[followUp.resultCode] : null, + createdAt: followUp.createdAt.toISOString(), + completedAt: followUp.completedAt?.toISOString() ?? null, + }; + } + + private toDetail(followUp: StoredFollowUp): FollowUpDetail { + return { + ...this.toListItem(followUp), + reason: this.sensitiveData.decrypt(followUp.reasonEncrypted), + resultNote: followUp.resultNoteEncrypted + ? this.sensitiveData.decrypt(followUp.resultNoteEncrypted) + : null, + continuedApplicationPublicId: followUp.continuedApplication?.publicId ?? null, + continuedApplicationNo: followUp.continuedApplication?.applicationNo ?? null, + }; + } + + private assertVisible(followUp: StoredFollowUp, actor: AuthContext): void { + if ( + followUp.ownerStaffId !== actor.userId && + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ) { + throw new ApplicationError( + API_ERROR_CODES.FORBIDDEN, + '无权访问该回访任务', + HttpStatus.FORBIDDEN, + ); + } + } + + private invalidTransition(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + '当前状态不能执行此操作,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + + private duplicateRequest(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '本次提交标识已用于其他内容,请重新操作', + HttpStatus.CONFLICT, + ); + } + + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + + private notFound(code: string, message: string): ApplicationError { + return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND); + } +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts new file mode 100644 index 0000000..cf54e94 --- /dev/null +++ b/apps/api/src/health/health.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Inject } from '@nestjs/common'; + +import type { HealthResponse } from '@lehuo/contracts'; + +import { Public } from '../common/decorators/public.decorator'; +import { RawResponse } from '../common/decorators/raw-response.decorator'; +import { HealthService } from './health.service'; + +@Controller('health') +@Public() +@RawResponse() +export class HealthController { + constructor(@Inject(HealthService) private readonly healthService: HealthService) {} + + @Get() + getHealth(): HealthResponse { + return this.healthService.getHealth(); + } + @Get('live') + liveness(): HealthResponse { + return this.healthService.getHealth(); + } + @Get('ready') + readiness() { + return this.healthService.readiness(); + } +} diff --git a/apps/api/src/health/health.service.spec.ts b/apps/api/src/health/health.service.spec.ts new file mode 100644 index 0000000..1818e82 --- /dev/null +++ b/apps/api/src/health/health.service.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { HealthService } from './health.service'; + +describe('HealthService', () => { + it('returns a healthy API response with an ISO timestamp', () => { + const response = new HealthService().getHealth(); + + expect(response.status).toBe('ok'); + expect(response.service).toBe('api'); + expect(Number.isNaN(Date.parse(response.timestamp))).toBe(false); + }); +}); diff --git a/apps/api/src/health/health.service.ts b/apps/api/src/health/health.service.ts new file mode 100644 index 0000000..1adc6e1 --- /dev/null +++ b/apps/api/src/health/health.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { HEALTH_STATUS, type HealthResponse } from '@lehuo/contracts'; +import { ServiceUnavailableException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class HealthService { + constructor(@Inject(PrismaService) private readonly prisma?: PrismaService) {} + getHealth(): HealthResponse { + return { + status: HEALTH_STATUS, + service: 'api', + timestamp: new Date().toISOString(), + }; + } + async readiness() { + try { + if (this.prisma) await this.prisma.$queryRaw`SELECT 1`; + return { + status: 'ready' as const, + database: 'up' as const, + timestamp: new Date().toISOString(), + }; + } catch { + throw new ServiceUnavailableException('Database readiness check failed'); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..9084e3a --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import cookieParser from 'cookie-parser'; + +import { AppModule } from './app.module'; +import { AppConfigService } from './config/app-config.service'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + const config = app.get(AppConfigService); + + app.use(cookieParser()); + app.enableCors({ origin: config.adminWebOrigin, credentials: true }); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ); + const openApi = new DocumentBuilder() + .setTitle('乐活公益暖心服务平台 API') + .setVersion('1.0') + .addBearerAuth() + .addCookieAuth('lehuo_admin_session') + .build(); + SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, openApi)); + app.enableShutdownHooks(); + await app.listen(config.apiPort, '0.0.0.0'); +} + +void bootstrap(); diff --git a/apps/api/src/participation/activities.service.ts b/apps/api/src/participation/activities.service.ts new file mode 100644 index 0000000..d691481 --- /dev/null +++ b/apps/api/src/participation/activities.service.ts @@ -0,0 +1,987 @@ +import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + ACTIVITY_STATUS_LABELS, + API_ERROR_CODES, + CHECK_IN_STATUS_LABELS, + PERMISSION_CODES, + REGISTRATION_STATUS_LABELS, + type ActivityDetail, + type ActivityDetailResponse, + type ActivityListItem, + type ActivityRegistrationItem, + type ActivityRegistrationResponse, + type PaginatedActivitiesResponse, + type PaginatedActivityRegistrationsResponse, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + ActivityQueryDto, + CheckInRegistrationDto, + CreateActivityDto, + PublicActivityQueryDto, + RecordServiceMinutesDto, + RegisterActivityDto, + RegistrationQueryDto, + ReviewRegistrationDto, + TransitionActivityDto, + UpdateActivityDto, +} from './dto/participation.dto'; +import { MediaService } from './media.service'; +import { NotificationsService } from '../content-notifications/notifications.service'; + +const activityInclude = { + coverFile: true, + project: true, + statusHistory: { orderBy: { createdAt: 'desc' as const } }, + registrations: { + where: { status: { in: ['PENDING' as const, 'APPROVED' as const] } }, + select: { id: true }, + }, +} satisfies Prisma.ActivityInclude; + +const registrationInclude = { + activity: true, + volunteer: true, +} satisfies Prisma.ActivityRegistrationInclude; + +type StoredActivity = Prisma.ActivityGetPayload<{ include: typeof activityInclude }>; +type StoredRegistration = Prisma.ActivityRegistrationGetPayload<{ + include: typeof registrationInclude; +}>; + +function dateNoPrefix(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}${value.month}${value.day}`; +} + +function maskMobile(value: string): string { + return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); +} + +@Injectable() +export class ActivitiesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(MediaService) private readonly media: MediaService, + @Inject(NotificationsService) private readonly notifications: NotificationsService, + ) {} + + listAdmin(query: ActivityQueryDto, actor: AuthContext): Promise { + const scoped: Prisma.ActivityWhereInput = actor.permissions.includes( + PERMISSION_CODES.RESOURCES_ACCESS_ALL, + ) + ? {} + : { OR: [{ createdById: actor.userId }, { project: { managerUserId: actor.userId } }] }; + return this.list( + { + ...scoped, + ...(query.status ? { status: query.status as never } : {}), + ...(query.projectPublicId + ? { + project: { + publicId: query.projectPublicId, + ...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ? { managerUserId: actor.userId } + : {}), + }, + } + : {}), + }, + query.page, + query.pageSize, + ); + } + + listPublic(query: PublicActivityQueryDto): Promise { + return this.list( + { + status: { in: ['PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED'] }, + ...(query.projectPublicId + ? { project: { publicId: query.projectPublicId, isPublic: true } } + : {}), + OR: [ + { projectId: null }, + { project: { isPublic: true, status: { in: ['PUBLISHED', 'COMPLETED'] } } }, + ], + }, + query.page, + query.pageSize, + ); + } + + async getAdmin(publicId: string, actor: AuthContext): Promise { + const activity = await this.find(publicId); + this.assertAccess(activity, actor); + return { activity: await this.detail(activity), currentUserRegistration: null }; + } + + async getPublic(publicId: string): Promise { + const activity = await this.find(publicId); + if (activity.status === 'DRAFT' || (activity.project && !activity.project.isPublic)) + throw this.notFound(); + return { activity: await this.detail(activity), currentUserRegistration: null }; + } + + async create( + input: CreateActivityDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const values = await this.prepare(input, actor); + const fingerprint = this.fingerprint('activity-create', { + ...input, + title: input.title.trim(), + summary: input.summary.trim(), + content: input.content.trim(), + locationText: input.locationText.trim(), + }); + const replay = await this.prisma.activity.findUnique({ + where: { + createdById_clientRequestId: { + createdById: actor.userId, + clientRequestId: input.clientRequestId, + }, + }, + include: activityInclude, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { + activity: await this.detail(replay), + currentUserRegistration: null, + idempotentReplay: true, + }; + } + const activity = await this.prisma.$transaction(async (transaction) => { + const created = await transaction.activity.create({ + data: { + publicId: randomUUID(), + activityNo: `HD${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + ...values, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await transaction.activityStatusHistory.create({ + data: { + activityId: created.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + toStatus: 'DRAFT', + operatorUserId: actor.userId, + reasonCode: 'CREATED', + }, + }); + await this.audit.record( + request, + { + action: 'ACTIVITY_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY', + resourcePublicId: created.publicId, + metadata: { status: 'DRAFT' }, + }, + transaction, + ); + return transaction.activity.findUniqueOrThrow({ + where: { id: created.id }, + include: activityInclude, + }); + }); + return { + activity: await this.detail(activity), + currentUserRegistration: null, + idempotentReplay: false, + }; + } + + async update( + publicId: string, + input: UpdateActivityDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const activity = await this.find(publicId); + this.assertAccess(activity, actor); + if (activity.status !== 'DRAFT') throw this.invalidTransition('只有草稿活动可以编辑'); + if (activity.revision !== input.expectedRevision) throw this.conflict(); + const values = await this.prepare(input, actor); + const updated = await this.prisma + .$transaction(async (transaction) => { + const changed = await transaction.activity.updateMany({ + where: { id: activity.id, status: 'DRAFT', revision: input.expectedRevision }, + data: { ...values, updatedById: actor.userId, revision: { increment: 1 } }, + }); + if (changed.count !== 1) throw new Error('ACTIVITY_CONFLICT'); + await this.audit.record( + request, + { + action: 'ACTIVITY_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY', + resourcePublicId: publicId, + }, + transaction, + ); + return transaction.activity.findUniqueOrThrow({ + where: { id: activity.id }, + include: activityInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'ACTIVITY_CONFLICT') throw this.conflict(); + throw error; + }); + return { activity: await this.detail(updated), currentUserRegistration: null }; + } + + async transition( + publicId: string, + input: TransitionActivityDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const activity = await this.find(publicId); + this.assertAccess(activity, actor); + const fingerprint = this.fingerprint('activity-transition', { + publicId, + targetStatus: input.targetStatus, + }); + const replay = await this.prisma.activityStatusHistory.findUnique({ + where: { requestId: input.clientRequestId }, + include: { activity: { include: activityInclude } }, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { + activity: await this.detail(replay.activity), + currentUserRegistration: null, + idempotentReplay: true, + }; + } + const allowed: Record = { + DRAFT: ['PUBLISHED', 'CANCELLED'], + PUBLISHED: ['REGISTRATION_CLOSED', 'CANCELLED'], + REGISTRATION_CLOSED: ['IN_PROGRESS', 'CANCELLED'], + IN_PROGRESS: ['COMPLETED'], + }; + if (!allowed[activity.status]?.includes(input.targetStatus)) throw this.invalidTransition(); + if (input.targetStatus === 'PUBLISHED' && activity.registrationDeadline <= new Date()) { + throw this.validation('报名截止时间必须晚于当前时间'); + } + const updated = await this.prisma + .$transaction(async (transaction) => { + const changed = await transaction.activity.updateMany({ + where: { id: activity.id, status: activity.status }, + data: { + status: input.targetStatus as never, + publishedAt: input.targetStatus === 'PUBLISHED' ? new Date() : activity.publishedAt, + updatedById: actor.userId, + revision: { increment: 1 }, + }, + }); + if (changed.count !== 1) throw new Error('ACTIVITY_STATE_CONFLICT'); + await transaction.activityStatusHistory.create({ + data: { + activityId: activity.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + fromStatus: activity.status, + toStatus: input.targetStatus as never, + operatorUserId: actor.userId, + reasonCode: input.targetStatus, + }, + }); + await this.audit.record( + request, + { + action: 'ACTIVITY_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY', + resourcePublicId: publicId, + metadata: { fromStatus: activity.status, toStatus: input.targetStatus }, + }, + transaction, + ); + return transaction.activity.findUniqueOrThrow({ + where: { id: activity.id }, + include: activityInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'ACTIVITY_STATE_CONFLICT') + throw this.invalidTransition(); + throw error; + }); + return { + activity: await this.detail(updated), + currentUserRegistration: null, + idempotentReplay: false, + }; + } + + async register( + activityPublicId: string, + input: RegisterActivityDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const fingerprint = this.fingerprint('activity-registration', { activityPublicId }); + const replay = await this.prisma.activityRegistration.findUnique({ + where: { + userId_clientRequestId: { userId: actor.userId, clientRequestId: input.clientRequestId }, + }, + include: registrationInclude, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { registration: this.registration(replay), idempotentReplay: true }; + } + const volunteer = await this.prisma.volunteer.findUnique({ where: { userId: actor.userId } }); + if (!volunteer || volunteer.status !== 'APPROVED') { + throw new ApplicationError( + API_ERROR_CODES.VOLUNTEER_NOT_APPROVED, + '仅已认证志愿者可以报名', + HttpStatus.FORBIDDEN, + ); + } + try { + const created = await this.prisma.$transaction(async (transaction) => { + const rows = await transaction.$queryRaw< + Array<{ id: bigint }> + >`SELECT id FROM activities WHERE public_id = ${activityPublicId} FOR UPDATE`; + if (!rows.length) throw new Error('ACTIVITY_NOT_FOUND'); + const activity = await transaction.activity.findUniqueOrThrow({ + where: { id: rows[0]!.id }, + }); + if (activity.status !== 'PUBLISHED' || activity.registrationDeadline <= new Date()) + throw new Error('REGISTRATION_CLOSED'); + const active = await transaction.activityRegistration.count({ + where: { activityId: activity.id, status: { in: ['PENDING', 'APPROVED'] } }, + }); + if (activity.capacity !== null && active >= activity.capacity) + throw new Error('ACTIVITY_FULL'); + const registration = await transaction.activityRegistration.create({ + data: { + publicId: randomUUID(), + activityId: activity.id, + userId: actor.userId, + volunteerId: volunteer.id, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + }, + }); + await transaction.activityRegistrationHistory.create({ + data: { + registrationId: registration.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + toStatus: 'PENDING', + operatorUserId: actor.userId, + actionCode: 'REGISTERED', + }, + }); + await this.audit.record( + request, + { + action: 'ACTIVITY_REGISTRATION_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY_REGISTRATION', + resourcePublicId: registration.publicId, + metadata: { activityPublicId }, + }, + transaction, + ); + return transaction.activityRegistration.findUniqueOrThrow({ + where: { id: registration.id }, + include: registrationInclude, + }); + }); + return { registration: this.registration(created), idempotentReplay: false }; + } catch (error) { + if (error instanceof Error && error.message === 'ACTIVITY_NOT_FOUND') throw this.notFound(); + if (error instanceof Error && error.message === 'REGISTRATION_CLOSED') + throw this.registrationClosed(); + if (error instanceof Error && error.message === 'ACTIVITY_FULL') throw this.full(); + if (this.uniqueError(error)) { + const existing = await this.prisma.activityRegistration.findFirst({ + where: { activity: { publicId: activityPublicId }, userId: actor.userId }, + include: registrationInclude, + }); + if ( + existing?.clientRequestId === input.clientRequestId && + existing.requestFingerprint === fingerprint + ) { + return { registration: this.registration(existing), idempotentReplay: true }; + } + throw this.duplicate('您已报名该活动'); + } + throw error; + } + } + + async myRegistrations( + query: RegistrationQueryDto, + actor: AuthContext, + ): Promise { + return this.listRegistrations( + { + userId: actor.userId, + ...(query.status ? { status: query.status as never } : {}), + ...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}), + }, + query.page, + query.pageSize, + ); + } + + async adminRegistrations( + query: RegistrationQueryDto, + ): Promise { + return this.listRegistrations( + { + ...(query.status ? { status: query.status as never } : {}), + ...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}), + }, + query.page, + query.pageSize, + ); + } + + async cancel( + publicId: string, + input: RegisterActivityDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const registration = await this.findRegistration(publicId); + if (registration.userId !== actor.userId) throw new ForbiddenException(); + const replay = await this.replayRegistration( + input.clientRequestId, + this.fingerprint('registration-status', { + publicId, + targetStatus: 'CANCELLED', + note: '', + }), + ); + if (replay) return { registration: this.registration(replay), idempotentReplay: true }; + if ( + !['PENDING', 'APPROVED'].includes(registration.status) || + registration.activity.startAt <= new Date() + ) + throw this.invalidTransition(); + return this.changeRegistrationStatus( + registration, + input.clientRequestId, + 'CANCELLED', + '', + actor, + request, + 'REGISTRATION_CANCELLED', + ); + } + + async reviewRegistration( + publicId: string, + input: ReviewRegistrationDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const registration = await this.findRegistration(publicId); + const replay = await this.replayRegistration( + input.clientRequestId, + this.fingerprint('registration-status', { + publicId, + targetStatus: input.targetStatus, + note: input.note.trim(), + }), + ); + if (replay) return { registration: this.registration(replay), idempotentReplay: true }; + if (registration.status !== 'PENDING') throw this.invalidTransition(); + return this.changeRegistrationStatus( + registration, + input.clientRequestId, + input.targetStatus, + input.note, + actor, + request, + 'REGISTRATION_REVIEWED', + ); + } + + async checkIn( + publicId: string, + input: CheckInRegistrationDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const registration = await this.findRegistration(publicId); + const fingerprint = this.fingerprint('registration-check-in', { + publicId, + targetStatus: input.targetStatus, + }); + const replay = await this.replayRegistration(input.clientRequestId, fingerprint); + if (replay) return { registration: this.registration(replay), idempotentReplay: true }; + if ( + registration.status !== 'APPROVED' || + registration.checkInStatus !== 'NOT_CHECKED_IN' || + registration.activity.status !== 'IN_PROGRESS' + ) { + throw this.invalidTransition(); + } + const updated = await this.prisma + .$transaction(async (transaction) => { + const changed = await transaction.activityRegistration.updateMany({ + where: { id: registration.id, status: 'APPROVED', checkInStatus: 'NOT_CHECKED_IN' }, + data: { + checkInStatus: input.targetStatus, + checkedInAt: input.targetStatus === 'CHECKED_IN' ? new Date() : null, + checkedInById: actor.userId, + }, + }); + if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT'); + await transaction.activityRegistrationHistory.create({ + data: { + registrationId: registration.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + fromCheckInStatus: 'NOT_CHECKED_IN', + toCheckInStatus: input.targetStatus, + operatorUserId: actor.userId, + actionCode: 'CHECK_IN_RECORDED', + }, + }); + await this.audit.record( + request, + { + action: 'ACTIVITY_CHECK_IN_RECORDED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY_REGISTRATION', + resourcePublicId: publicId, + metadata: { checkInStatus: input.targetStatus }, + }, + transaction, + ); + return transaction.activityRegistration.findUniqueOrThrow({ + where: { id: registration.id }, + include: registrationInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT') + throw this.invalidTransition(); + throw error; + }); + return { registration: this.registration(updated), idempotentReplay: false }; + } + + async recordMinutes( + publicId: string, + input: RecordServiceMinutesDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const registration = await this.findRegistration(publicId); + if ( + registration.status !== 'APPROVED' || + registration.checkInStatus !== 'CHECKED_IN' || + registration.activity.status !== 'COMPLETED' + ) { + throw this.invalidTransition(); + } + const durationMinutes = Math.ceil( + (registration.activity.endAt.getTime() - registration.activity.startAt.getTime()) / 60_000, + ); + if (input.serviceMinutes > durationMinutes) + throw this.validation('服务时长不能超过活动持续时间'); + const fingerprint = this.fingerprint('registration-service-minutes', { + publicId, + serviceMinutes: input.serviceMinutes, + }); + const replay = await this.replayRegistration(input.clientRequestId, fingerprint); + if (replay) return { registration: this.registration(replay), idempotentReplay: true }; + const updated = await this.prisma.$transaction(async (transaction) => { + const current = await transaction.activityRegistration.findUniqueOrThrow({ + where: { id: registration.id }, + }); + await transaction.activityRegistration.update({ + where: { id: registration.id }, + data: { + serviceMinutes: input.serviceMinutes, + serviceMinutesRecordedAt: new Date(), + serviceMinutesRecordedById: actor.userId, + }, + }); + await transaction.activityRegistrationHistory.create({ + data: { + registrationId: registration.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + serviceMinutesBefore: current.serviceMinutes, + serviceMinutesAfter: input.serviceMinutes, + operatorUserId: actor.userId, + actionCode: 'SERVICE_MINUTES_RECORDED', + }, + }); + await this.audit.record( + request, + { + action: 'VOLUNTEER_SERVICE_MINUTES_RECORDED', + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY_REGISTRATION', + resourcePublicId: publicId, + metadata: { + serviceMinutesBefore: current.serviceMinutes, + serviceMinutesAfter: input.serviceMinutes, + }, + }, + transaction, + ); + return transaction.activityRegistration.findUniqueOrThrow({ + where: { id: registration.id }, + include: registrationInclude, + }); + }); + return { registration: this.registration(updated), idempotentReplay: false }; + } + + private async changeRegistrationStatus( + registration: StoredRegistration, + requestId: string, + targetStatus: 'APPROVED' | 'REJECTED' | 'CANCELLED', + note: string, + actor: AuthContext, + request: RequestContext, + action: string, + ): Promise { + const fingerprint = this.fingerprint('registration-status', { + publicId: registration.publicId, + targetStatus, + note: note.trim(), + }); + const replay = await this.replayRegistration(requestId, fingerprint); + if (replay) return { registration: this.registration(replay), idempotentReplay: true }; + const updated = await this.prisma + .$transaction(async (transaction) => { + const changed = await transaction.activityRegistration.updateMany({ + where: { id: registration.id, status: registration.status }, + data: { + status: targetStatus, + reviewedById: targetStatus === 'CANCELLED' ? null : actor.userId, + reviewedAt: targetStatus === 'CANCELLED' ? null : new Date(), + reviewNoteEncrypted: note ? this.sensitive.encrypt(note.trim()) : null, + }, + }); + if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT'); + await transaction.activityRegistrationHistory.create({ + data: { + registrationId: registration.id, + requestId, + requestFingerprint: fingerprint, + fromStatus: registration.status, + toStatus: targetStatus, + operatorUserId: actor.userId, + actionCode: action, + }, + }); + await this.audit.record( + request, + { + action, + result: 'SUCCESS', + actor, + resourceType: 'ACTIVITY_REGISTRATION', + resourcePublicId: registration.publicId, + metadata: { fromStatus: registration.status, toStatus: targetStatus }, + }, + transaction, + ); + if (targetStatus !== 'CANCELLED') + await this.notifications.create(transaction, { + userId: registration.userId, + eventKey: `registration:${registration.publicId}:${targetStatus}`, + type: 'ACTIVITY_REGISTRATION_RESULT', + title: targetStatus === 'APPROVED' ? '活动报名已通过' : '活动报名未通过', + body: + targetStatus === 'APPROVED' + ? '您的活动报名已通过,请留意活动时间和地点。' + : '很抱歉,本次活动报名未通过,您可以继续关注其他活动。', + resourceType: 'ACTIVITY_REGISTRATION', + resourcePublicId: registration.publicId, + }); + return transaction.activityRegistration.findUniqueOrThrow({ + where: { id: registration.id }, + include: registrationInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT') + throw this.invalidTransition(); + throw error; + }); + return { registration: this.registration(updated), idempotentReplay: false }; + } + + private async prepare(input: CreateActivityDto | UpdateActivityDto, actor: AuthContext) { + const startAt = new Date(input.startAt), + endAt = new Date(input.endAt), + registrationDeadline = new Date(input.registrationDeadline); + if (endAt <= startAt) throw this.validation('活动结束时间必须晚于开始时间'); + if (registrationDeadline >= startAt) throw this.validation('报名截止时间必须早于活动开始时间'); + let projectId: bigint | undefined; + if (input.projectPublicId) { + const project = await this.prisma.project.findUnique({ + where: { publicId: input.projectPublicId }, + }); + if (!project || project.status === 'COMPLETED') throw this.validation('关联项目无效或已完成'); + if ( + project.managerUserId !== actor.userId && + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ) + throw new ForbiddenException(); + projectId = project.id; + } + const cover = await this.media.requireCover(input.coverFilePublicId, 'ACTIVITY_COVER', actor); + return { + projectId, + title: input.title.trim(), + summary: input.summary.trim(), + content: input.content.trim(), + coverFileId: cover?.id, + startAt, + endAt, + registrationDeadline, + locationText: input.locationText.trim(), + capacity: input.capacity, + }; + } + + private async list( + where: Prisma.ActivityWhereInput, + page: number, + pageSize: number, + ): Promise { + const [total, activities] = await this.prisma.$transaction([ + this.prisma.activity.count({ where }), + this.prisma.activity.findMany({ + where, + include: activityInclude, + orderBy: { startAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + return { + items: await Promise.all(activities.map((activity) => this.item(activity))), + page, + pageSize, + total, + }; + } + + private async listRegistrations( + where: Prisma.ActivityRegistrationWhereInput, + page: number, + pageSize: number, + ): Promise { + const [total, registrations] = await this.prisma.$transaction([ + this.prisma.activityRegistration.count({ where }), + this.prisma.activityRegistration.findMany({ + where, + include: registrationInclude, + orderBy: { registeredAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + return { items: registrations.map((item) => this.registration(item)), page, pageSize, total }; + } + + private async find(publicId: string): Promise { + const activity = await this.prisma.activity.findUnique({ + where: { publicId }, + include: activityInclude, + }); + if (!activity) throw this.notFound(); + return activity; + } + + private async findRegistration(publicId: string): Promise { + const registration = await this.prisma.activityRegistration.findUnique({ + where: { publicId }, + include: registrationInclude, + }); + if (!registration) + throw new ApplicationError( + API_ERROR_CODES.REGISTRATION_NOT_FOUND, + '未找到该报名记录', + HttpStatus.NOT_FOUND, + ); + return registration; + } + + private replayRegistration( + requestId: string, + fingerprint: string, + ): Promise { + return this.prisma.activityRegistrationHistory + .findUnique({ + where: { requestId }, + include: { registration: { include: registrationInclude } }, + }) + .then((history) => { + if (!history) return null; + if (history.requestFingerprint !== fingerprint) throw this.duplicate(); + return history.registration; + }); + } + + private assertAccess(activity: StoredActivity, actor: AuthContext): void { + if ( + activity.createdById !== actor.userId && + activity.project?.managerUserId !== actor.userId && + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ) { + throw new ForbiddenException(); + } + } + + private async item(activity: StoredActivity): Promise { + const active = activity.registrations.length; + return { + publicId: activity.publicId, + activityNo: activity.activityNo, + projectPublicId: activity.project?.publicId ?? null, + projectName: activity.project?.name ?? null, + title: activity.title, + summary: activity.summary, + coverImageUrl: await this.media.publicUrl(activity.coverFile), + startAt: activity.startAt.toISOString(), + endAt: activity.endAt.toISOString(), + registrationDeadline: activity.registrationDeadline.toISOString(), + locationText: activity.locationText, + capacity: activity.capacity, + activeRegistrationCount: active, + remainingCapacity: + activity.capacity === null ? null : Math.max(0, activity.capacity - active), + status: activity.status, + statusLabel: ACTIVITY_STATUS_LABELS[activity.status], + canRegister: + activity.status === 'PUBLISHED' && + activity.registrationDeadline > new Date() && + (activity.capacity === null || active < activity.capacity), + revision: activity.revision, + createdAt: activity.createdAt.toISOString(), + }; + } + + private async detail(activity: StoredActivity): Promise { + return { + ...(await this.item(activity)), + content: activity.content, + coverFilePublicId: activity.coverFile?.publicId ?? null, + history: activity.statusHistory.map((history) => ({ + status: history.toStatus, + statusLabel: ACTIVITY_STATUS_LABELS[history.toStatus], + reasonLabel: + ( + { + CREATED: '创建活动', + PUBLISHED: '发布活动', + REGISTRATION_CLOSED: '关闭报名', + IN_PROGRESS: '开始活动', + COMPLETED: '完成活动', + CANCELLED: '取消活动', + } as Record + )[history.reasonCode] ?? '状态更新', + createdAt: history.createdAt.toISOString(), + })), + }; + } + + private registration(item: StoredRegistration): ActivityRegistrationItem { + const mobile = this.sensitive.decrypt(item.volunteer.mobileEncrypted); + return { + publicId: item.publicId, + activityPublicId: item.activity.publicId, + activityTitle: item.activity.title, + volunteerPublicId: item.volunteer.publicId, + volunteerName: this.sensitive.decrypt(item.volunteer.realNameEncrypted), + mobileMasked: maskMobile(mobile), + status: item.status, + statusLabel: REGISTRATION_STATUS_LABELS[item.status], + checkInStatus: item.checkInStatus, + checkInStatusLabel: CHECK_IN_STATUS_LABELS[item.checkInStatus], + serviceMinutes: item.serviceMinutes, + registeredAt: item.registeredAt.toISOString(), + }; + } + + private fingerprint(domain: string, value: object): string { + return this.sensitive.lookupHash(domain, JSON.stringify(value)); + } + private uniqueError(error: unknown): error is { code: 'P2002' } { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; + } + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.ACTIVITY_NOT_FOUND, + '未找到该公益活动', + HttpStatus.NOT_FOUND, + ); + } + private conflict(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.RESOURCE_CONFLICT, + '活动已更新,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + private invalidTransition(message = '当前状态不能执行该操作'): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + message, + HttpStatus.CONFLICT, + ); + } + private duplicate(message = '请求标识已用于其他操作'): ApplicationError { + return new ApplicationError(API_ERROR_CODES.DUPLICATE_REQUEST, message, HttpStatus.CONFLICT); + } + private registrationClosed(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.REGISTRATION_CLOSED, + '活动报名已截止', + HttpStatus.CONFLICT, + ); + } + private full(): ApplicationError { + return new ApplicationError(API_ERROR_CODES.ACTIVITY_FULL, '活动名额已满', HttpStatus.CONFLICT); + } +} diff --git a/apps/api/src/participation/admin-participation.controller.ts b/apps/api/src/participation/admin-participation.controller.ts new file mode 100644 index 0000000..31ae0d2 --- /dev/null +++ b/apps/api/src/participation/admin-participation.controller.ts @@ -0,0 +1,294 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Put, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import { + PERMISSION_CODES, + type ActivityDetailResponse, + type ActivityRegistrationResponse, + type AssigneeOption, + type AdminVolunteerDetailResponse, + type ConfirmUploadResponse, + type CreateUploadIntentResponse, + type PaginatedActivitiesResponse, + type PaginatedActivityRegistrationsResponse, + type PaginatedProjectsResponse, + type PaginatedVolunteersResponse, + type ProjectDetailResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { ActivitiesService } from './activities.service'; +import { + ActivityQueryDto, + CheckInRegistrationDto, + CreateActivityDto, + CreateMediaUploadIntentDto, + CreateProjectDto, + ProjectQueryDto, + RecordServiceMinutesDto, + RegistrationQueryDto, + ReplaceProjectCasesDto, + RestoreVolunteerDto, + ReviewRegistrationDto, + ReviewVolunteerDto, + TransitionActivityDto, + TransitionProjectDto, + UpdateActivityDto, + UpdateProjectDto, + VolunteerQueryDto, +} from './dto/participation.dto'; +import { MediaService } from './media.service'; +import { ProjectsService } from './projects.service'; +import { VolunteersService } from './volunteers.service'; + +const strict = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); + +@Controller('api/v1/admin') +@RequireSessionChannel(SessionChannel.ADMIN) +export class AdminParticipationController { + constructor( + @Inject(VolunteersService) private readonly volunteers: VolunteersService, + @Inject(ProjectsService) private readonly projects: ProjectsService, + @Inject(ActivitiesService) private readonly activities: ActivitiesService, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + @Get('volunteers') + @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ) + volunteersList( + @Query(strict(VolunteerQueryDto)) query: VolunteerQueryDto, + ): Promise { + return this.volunteers.list(query); + } + + @Get('volunteers/:publicId') + @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ) + volunteer( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.volunteers.get(publicId, actor, request); + } + + @Post('volunteers/:publicId/review') + @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW) + reviewVolunteer( + @Param('publicId') publicId: string, + @Body(strict(ReviewVolunteerDto)) input: ReviewVolunteerDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.volunteers.review(publicId, input, actor, request); + } + + @Post('volunteers/:publicId/restore') + @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW) + restoreVolunteer( + @Param('publicId') publicId: string, + @Body(strict(RestoreVolunteerDto)) input: RestoreVolunteerDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.volunteers.review(publicId, input, actor, request); + } + + @Get('projects') + @RequirePermissions(PERMISSION_CODES.PROJECTS_READ) + projectsList( + @Query(strict(ProjectQueryDto)) query: ProjectQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.projects.listAdmin(query, actor); + } + + @Get('project-manager-options') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + projectManagerOptions(): Promise { + return this.projects.managerOptions(); + } + + @Post('projects') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + createProject( + @Body(strict(CreateProjectDto)) input: CreateProjectDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.projects.create(input, actor, request); + } + + @Get('projects/:publicId') + @RequirePermissions(PERMISSION_CODES.PROJECTS_READ) + project( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.projects.getAdmin(publicId, actor); + } + + @Patch('projects/:publicId') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + updateProject( + @Param('publicId') publicId: string, + @Body(strict(UpdateProjectDto)) input: UpdateProjectDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.projects.update(publicId, input, actor, request); + } + + @Post('projects/:publicId/transition') + @RequirePermissions(PERMISSION_CODES.PROJECTS_PUBLISH) + transitionProject( + @Param('publicId') publicId: string, + @Body(strict(TransitionProjectDto)) input: TransitionProjectDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.projects.transition(publicId, input, actor, request); + } + + @Put('projects/:publicId/service-cases') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + replaceProjectCases( + @Param('publicId') publicId: string, + @Body(strict(ReplaceProjectCasesDto)) input: ReplaceProjectCasesDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.projects.replaceCases(publicId, input, actor, request); + } + + @Get('activities') + @RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ) + activitiesList( + @Query(strict(ActivityQueryDto)) query: ActivityQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.activities.listAdmin(query, actor); + } + + @Post('activities') + @RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE) + createActivity( + @Body(strict(CreateActivityDto)) input: CreateActivityDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.create(input, actor, request); + } + + @Get('activities/:publicId') + @RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ) + activity( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.activities.getAdmin(publicId, actor); + } + + @Patch('activities/:publicId') + @RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE) + updateActivity( + @Param('publicId') publicId: string, + @Body(strict(UpdateActivityDto)) input: UpdateActivityDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.update(publicId, input, actor, request); + } + + @Post('activities/:publicId/transition') + @RequirePermissions(PERMISSION_CODES.ACTIVITIES_PUBLISH) + transitionActivity( + @Param('publicId') publicId: string, + @Body(strict(TransitionActivityDto)) input: TransitionActivityDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.transition(publicId, input, actor, request); + } + + @Get('activity-registrations') + @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE) + registrationList( + @Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto, + ): Promise { + return this.activities.adminRegistrations(query); + } + + @Post('activity-registrations/:publicId/review') + @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE) + reviewRegistration( + @Param('publicId') publicId: string, + @Body(strict(ReviewRegistrationDto)) input: ReviewRegistrationDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.reviewRegistration(publicId, input, actor, request); + } + + @Post('activity-registrations/:publicId/check-in') + @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE) + checkIn( + @Param('publicId') publicId: string, + @Body(strict(CheckInRegistrationDto)) input: CheckInRegistrationDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.checkIn(publicId, input, actor, request); + } + + @Post('activity-registrations/:publicId/service-minutes') + @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE) + serviceMinutes( + @Param('publicId') publicId: string, + @Body(strict(RecordServiceMinutesDto)) input: RecordServiceMinutesDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.recordMinutes(publicId, input, actor, request); + } + + @Post('media/upload-intents') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + mediaIntent( + @Body(strict(CreateMediaUploadIntentDto)) input: CreateMediaUploadIntentDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.media.createIntent(input, actor, request); + } + + @Post('media/:publicId/confirm') + @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE) + confirmMedia( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.media.confirm(publicId, actor, request); + } +} diff --git a/apps/api/src/participation/dto/participation.dto.ts b/apps/api/src/participation/dto/participation.dto.ts new file mode 100644 index 0000000..1608c0a --- /dev/null +++ b/apps/api/src/participation/dto/participation.dto.ts @@ -0,0 +1,169 @@ +import { Type } from 'class-transformer'; +import { OmitType } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Length, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; + +import { + ACTIVITY_STATUSES, + CHECK_IN_STATUSES, + PROJECT_STATUSES, + REGISTRATION_STATUSES, + SERVICE_CATEGORIES, + VOLUNTEER_SKILL_CODES, + VOLUNTEER_STATUSES, +} from '@lehuo/contracts'; + +import { PaginationDto } from '../../case-management/dto/case-management.dto'; + +const MOBILE_PATTERN = /^1[3-9]\d{9}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export class ApplyVolunteerDto { + @IsUUID('4') clientRequestId!: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) expectedRevision?: number; + @IsString() @Length(2, 50) realName!: string; + @Matches(MOBILE_PATTERN) mobile!: string; + @IsString() @Length(2, 100) regionCode!: string; + @IsString() @Length(2, 500) availabilityNote!: string; + @IsArray() @ArrayMaxSize(12) @IsIn(VOLUNTEER_SKILL_CODES, { each: true }) skillCodes!: string[]; + @IsArray() + @ArrayMaxSize(12) + @IsIn(SERVICE_CATEGORIES, { each: true }) + interestCategories!: string[]; + @IsIn([true]) privacyConsent!: true; +} + +export class VolunteerQueryDto extends PaginationDto { + @IsOptional() @IsIn(VOLUNTEER_STATUSES) status?: string; + @IsOptional() @IsString() @MaxLength(100) regionCode?: string; +} + +export class ReviewVolunteerDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(['APPROVED', 'REJECTED', 'SUSPENDED']) targetStatus!: 'APPROVED' | 'REJECTED' | 'SUSPENDED'; + @IsString() @Length(2, 500) note!: string; +} + +export class RestoreVolunteerDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(['APPROVED']) targetStatus!: 'APPROVED'; + @IsString() @Length(2, 500) note!: string; +} + +export class ProjectQueryDto extends PaginationDto { + @IsOptional() @IsIn(PROJECT_STATUSES) status?: string; + @IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string; +} + +export class PublicProjectQueryDto extends PaginationDto { + @IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string; +} + +export class CreateProjectDto { + @IsUUID('4') clientRequestId!: string; + @IsString() @Length(2, 200) name!: string; + @IsIn(SERVICE_CATEGORIES) category!: string; + @IsString() @Length(10, 500) summary!: string; + @IsString() @Length(20, 20000) content!: string; + @IsOptional() @IsUUID('4') coverFilePublicId?: string; + @IsUUID('4') managerUserPublicId!: string; + @Matches(DATE_PATTERN) startDate!: string; + @Matches(DATE_PATTERN) endDate!: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1_000_000) targetPeople?: number; + @IsBoolean() isPublic!: boolean; + @Type(() => Number) @IsInt() @Min(-10_000) @Max(10_000) sortOrder!: number; +} + +export class UpdateProjectDto extends OmitType(CreateProjectDto, ['clientRequestId'] as const) { + @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number; +} + +export class TransitionProjectDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(['PUBLISHED', 'COMPLETED']) targetStatus!: 'PUBLISHED' | 'COMPLETED'; +} + +export class ReplaceProjectCasesDto { + @IsArray() @ArrayMaxSize(500) @IsUUID('4', { each: true }) serviceCasePublicIds!: string[]; +} + +export class ActivityQueryDto extends PaginationDto { + @IsOptional() @IsIn(ACTIVITY_STATUSES) status?: string; + @IsOptional() @IsUUID('4') projectPublicId?: string; +} + +export class PublicActivityQueryDto extends PaginationDto { + @IsOptional() @IsUUID('4') projectPublicId?: string; +} + +export class CreateActivityDto { + @IsUUID('4') clientRequestId!: string; + @IsOptional() @IsUUID('4') projectPublicId?: string; + @IsString() @Length(2, 200) title!: string; + @IsString() @Length(10, 500) summary!: string; + @IsString() @Length(20, 20000) content!: string; + @IsOptional() @IsUUID('4') coverFilePublicId?: string; + @IsISO8601({ strict: true }) startAt!: string; + @IsISO8601({ strict: true }) endAt!: string; + @IsISO8601({ strict: true }) registrationDeadline!: string; + @IsString() @Length(2, 300) locationText!: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100_000) capacity?: number; +} + +export class UpdateActivityDto extends OmitType(CreateActivityDto, ['clientRequestId'] as const) { + @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number; +} + +export class TransitionActivityDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(ACTIVITY_STATUSES.filter((status) => status !== 'DRAFT')) targetStatus!: string; +} + +export class RegisterActivityDto { + @IsUUID('4') clientRequestId!: string; +} + +export class RegistrationQueryDto extends PaginationDto { + @IsOptional() @IsIn(REGISTRATION_STATUSES) status?: string; + @IsOptional() @IsUUID('4') activityPublicId?: string; +} + +export class ReviewRegistrationDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(['APPROVED', 'REJECTED']) targetStatus!: 'APPROVED' | 'REJECTED'; + @IsString() @Length(2, 500) note!: string; +} + +export class CheckInRegistrationDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(CHECK_IN_STATUSES.filter((status) => status !== 'NOT_CHECKED_IN')) targetStatus!: + 'CHECKED_IN' | 'ABSENT'; +} + +export class RecordServiceMinutesDto { + @IsUUID('4') clientRequestId!: string; + @Type(() => Number) @IsInt() @Min(1) @Max(100_800) serviceMinutes!: number; +} + +export class CreateMediaUploadIntentDto { + @IsUUID('4') clientRequestId!: string; + @IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER'; + @IsString() @Length(1, 255) filename!: string; + @IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string; + @Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number; + @IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string; +} diff --git a/apps/api/src/participation/me-participation.controller.ts b/apps/api/src/participation/me-participation.controller.ts new file mode 100644 index 0000000..f7c0809 --- /dev/null +++ b/apps/api/src/participation/me-participation.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + Put, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; + +import type { + ActivityRegistrationResponse, + PaginatedActivityRegistrationsResponse, + VolunteerProfileResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { ActivitiesService } from './activities.service'; +import { + ApplyVolunteerDto, + RegisterActivityDto, + RegistrationQueryDto, +} from './dto/participation.dto'; +import { VolunteersService } from './volunteers.service'; + +const strict = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); + +@Controller('api/v1') +@RequireSessionChannel(SessionChannel.WECHAT) +export class MeParticipationController { + constructor( + @Inject(VolunteersService) private readonly volunteers: VolunteersService, + @Inject(ActivitiesService) private readonly activities: ActivitiesService, + ) {} + + @Get('me/volunteer') + mine(@CurrentAuth() actor: AuthContext): Promise { + return this.volunteers.mine(actor); + } + + @Put('me/volunteer') + apply( + @Body(strict(ApplyVolunteerDto)) input: ApplyVolunteerDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.volunteers.apply(input, actor, request); + } + + @Get('me/activity-registrations') + registrations( + @Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.activities.myRegistrations(query, actor); + } + + @Post('activities/:publicId/registrations') + register( + @Param('publicId') publicId: string, + @Body(strict(RegisterActivityDto)) input: RegisterActivityDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.register(publicId, input, actor, request); + } + + @Post('me/activity-registrations/:publicId/cancel') + cancel( + @Param('publicId') publicId: string, + @Body(strict(RegisterActivityDto)) input: RegisterActivityDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.activities.cancel(publicId, input, actor, request); + } +} diff --git a/apps/api/src/participation/media.service.ts b/apps/api/src/participation/media.service.ts new file mode 100644 index 0000000..6f7c014 --- /dev/null +++ b/apps/api/src/participation/media.service.ts @@ -0,0 +1,234 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + type ConfirmUploadResponse, + type CreateUploadIntentResponse, + type ServiceRecordAttachment, +} from '@lehuo/contracts'; + +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { FilesRepository } from '../files/files.repository'; +import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter'; +import type { File, FileBusinessType } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { CreateMediaUploadIntentDto } from './dto/participation.dto'; + +const EXTENSIONS: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +@Injectable() +export class MediaService { + constructor( + @Inject(FilesRepository) private readonly files: FilesRepository, + @Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + ) {} + + async createIntent( + input: CreateMediaUploadIntentDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertConfigured(); + const filename = this.safeFilename(input.filename); + const extension = EXTENSIONS[input.mimeType]; + if (!extension || !this.extensionMatches(filename, extension)) { + throw this.validation('文件扩展名与图片类型不一致'); + } + const fingerprint = this.sensitive.lookupHash( + 'cover-upload-intent', + JSON.stringify({ ...input, filename }), + ); + const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId); + if (previous) { + if ( + previous.requestFingerprint !== fingerprint || + previous.businessType !== input.businessType + ) { + throw this.duplicate(); + } + return this.uploadResponse(previous, true); + } + const now = new Date(); + const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`; + const file = await this.files.createPending( + { + publicId: randomUUID(), + businessType: input.businessType, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + provider: this.storage.providerName, + bucket: this.storage.bucket ?? '', + objectKey, + filenameEncrypted: this.sensitive.encrypt(filename), + mimeType: input.mimeType, + size: BigInt(input.size), + sha256: input.sha256, + }, + actor, + request, + ); + return this.uploadResponse(file, false); + } + + async confirm( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertConfigured(); + const file = await this.files.findByPublicId(publicId); + if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId) + throw this.notFound(); + if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') { + throw this.notFound(); + } + if (file.status === 'AVAILABLE') return { file: this.attachment(file) }; + if (file.status !== 'UPLOAD_PENDING') throw this.notReady(); + let metadata; + try { + metadata = await this.storage.headObject(file.objectKey); + } catch { + throw this.storageUnavailable(); + } + if (!metadata) throw this.notReady(); + if ( + metadata.contentLength !== Number(file.size) || + metadata.contentType !== file.mimeType || + (file.sha256 !== null && metadata.sha256 !== file.sha256) + ) { + await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH'); + throw this.validation('图片校验未通过,请重新选择文件'); + } + return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) }; + } + + async requireCover( + publicId: string | undefined, + businessType: FileBusinessType, + actor: AuthContext, + ): Promise { + if (!publicId) return null; + const file = await this.files.findByPublicId(publicId); + if ( + !file || + file.status !== 'AVAILABLE' || + file.businessType !== businessType || + file.uploaderUserId !== actor.userId || + file.caseId !== null + ) { + throw this.validation('封面图片无效或尚未确认上传'); + } + return file; + } + + async publicUrl(file: Pick | null): Promise { + if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null; + try { + return await this.storage.createDownloadUrl(file.objectKey, 600); + } catch { + return null; + } + } + + private async uploadResponse( + file: File, + idempotentReplay: boolean, + ): Promise { + const expiresIn = 600; + let uploadUrl: string; + try { + uploadUrl = await this.storage.createUploadUrl( + file.objectKey, + file.mimeType, + file.sha256 ?? undefined, + expiresIn, + ); + } catch { + throw this.storageUnavailable(); + } + return { + file: this.attachment(file), + uploadUrl, + uploadHeaders: { + 'Content-Type': file.mimeType, + ...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}), + }, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + idempotentReplay, + }; + } + + private attachment(file: File): ServiceRecordAttachment { + return { + publicId: file.publicId, + filename: this.sensitive.decrypt(file.originalFilenameEncrypted), + mimeType: file.mimeType, + size: Number(file.size), + }; + } + + private safeFilename(value: string): string { + const filename = value.trim(); + if ( + !filename || + filename.includes('/') || + filename.includes('\\') || + Array.from(filename).some((character) => character.charCodeAt(0) <= 31) + ) { + throw this.validation('文件名不合法'); + } + return filename; + } + + private extensionMatches(filename: string, extension: string): boolean { + const lower = filename.toLowerCase(); + return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg')); + } + + private assertConfigured(): void { + if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable(); + } + + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + + private duplicate(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '请求标识已用于其他文件', + HttpStatus.CONFLICT, + ); + } + + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.FILE_NOT_FOUND, + '未找到该图片', + HttpStatus.NOT_FOUND, + ); + } + + private notReady(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.UPLOAD_NOT_READY, + '尚未收到上传文件,请完成上传后重试', + HttpStatus.CONFLICT, + ); + } + + private storageUnavailable(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.STORAGE_UNAVAILABLE, + '图片服务尚未配置或暂时不可用', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} diff --git a/apps/api/src/participation/participation.module.ts b/apps/api/src/participation/participation.module.ts new file mode 100644 index 0000000..958747d --- /dev/null +++ b/apps/api/src/participation/participation.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { ContentNotificationsModule } from '../content-notifications/content-notifications.module'; +import { FilesModule } from '../files/files.module'; +import { ActivitiesService } from './activities.service'; +import { AdminParticipationController } from './admin-participation.controller'; +import { MeParticipationController } from './me-participation.controller'; +import { MediaService } from './media.service'; +import { ProjectsService } from './projects.service'; +import { PublicParticipationController } from './public-participation.controller'; +import { VolunteersService } from './volunteers.service'; + +@Module({ + imports: [AuditModule, FilesModule, ContentNotificationsModule], + controllers: [ + PublicParticipationController, + MeParticipationController, + AdminParticipationController, + ], + providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService], +}) +export class ParticipationModule {} diff --git a/apps/api/src/participation/projects.service.ts b/apps/api/src/participation/projects.service.ts new file mode 100644 index 0000000..f198b0a --- /dev/null +++ b/apps/api/src/participation/projects.service.ts @@ -0,0 +1,496 @@ +import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + PERMISSION_CODES, + PROJECT_STATUS_LABELS, + type PaginatedProjectsResponse, + type ProjectDetail, + type ProjectDetailResponse, + type ProjectListItem, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + CreateProjectDto, + ProjectQueryDto, + PublicProjectQueryDto, + ReplaceProjectCasesDto, + TransitionProjectDto, + UpdateProjectDto, +} from './dto/participation.dto'; +import { MediaService } from './media.service'; + +const projectInclude = { + coverFile: true, + manager: { include: { staffProfile: true } }, + statusHistory: { orderBy: { createdAt: 'desc' as const } }, + _count: { select: { activities: true, serviceCases: true } }, +} satisfies Prisma.ProjectInclude; + +type StoredProject = Prisma.ProjectGetPayload<{ include: typeof projectInclude }>; + +function dateNoPrefix(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}${value.month}${value.day}`; +} + +@Injectable() +export class ProjectsService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + async managerOptions() { + const users = await this.prisma.user.findMany({ + where: { + status: 'ACTIVE', + staffProfile: { is: { status: 'ACTIVE' } }, + userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } }, + }, + include: { staffProfile: true }, + orderBy: { staffProfile: { displayName: 'asc' } }, + }); + return users.map((user) => ({ + userPublicId: user.publicId, + displayName: user.staffProfile?.displayName ?? '项目负责人', + type: 'STAFF' as const, + department: user.staffProfile?.department ?? null, + })); + } + + async listAdmin(query: ProjectQueryDto, actor: AuthContext): Promise { + const where: Prisma.ProjectWhereInput = { + ...(query.status ? { status: query.status as never } : {}), + ...(query.category ? { category: query.category as never } : {}), + ...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ? { managerUserId: actor.userId } + : {}), + }; + return this.list(where, query.page, query.pageSize); + } + + async listPublic(query: PublicProjectQueryDto): Promise { + return this.list( + { + isPublic: true, + status: { in: ['PUBLISHED', 'COMPLETED'] }, + ...(query.category ? { category: query.category as never } : {}), + }, + query.page, + query.pageSize, + ); + } + + async getAdmin(publicId: string, actor: AuthContext): Promise { + const project = await this.find(publicId); + this.assertAccess(project, actor); + return { project: await this.detail(project) }; + } + + async getPublic(publicId: string): Promise { + const project = await this.find(publicId); + if (!project.isPublic || !['PUBLISHED', 'COMPLETED'].includes(project.status)) + throw this.notFound(); + return { project: await this.detail(project) }; + } + + async create( + input: CreateProjectDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const values = await this.prepare(input, actor); + const fingerprint = this.fingerprint('project-create', { + ...input, + name: input.name.trim(), + summary: input.summary.trim(), + content: input.content.trim(), + }); + const replay = await this.prisma.project.findUnique({ + where: { + createdById_clientRequestId: { + createdById: actor.userId, + clientRequestId: input.clientRequestId, + }, + }, + include: projectInclude, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { project: await this.detail(replay), idempotentReplay: true }; + } + const project = await this.prisma.$transaction(async (transaction) => { + const created = await transaction.project.create({ + data: { + publicId: randomUUID(), + projectNo: `XM${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + ...values, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + await transaction.projectStatusHistory.create({ + data: { + projectId: created.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + toStatus: 'DRAFT', + operatorUserId: actor.userId, + reasonCode: 'CREATED', + }, + }); + await this.audit.record( + request, + { + action: 'PROJECT_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'PROJECT', + resourcePublicId: created.publicId, + metadata: { status: 'DRAFT' }, + }, + transaction, + ); + return transaction.project.findUniqueOrThrow({ + where: { id: created.id }, + include: projectInclude, + }); + }); + return { project: await this.detail(project), idempotentReplay: false }; + } + + async update( + publicId: string, + input: UpdateProjectDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const project = await this.find(publicId); + this.assertAccess(project, actor); + if (project.status !== 'DRAFT') throw this.invalidTransition('只有草稿项目可以编辑'); + if (project.revision !== input.expectedRevision) throw this.conflict(); + const values = await this.prepare(input, actor); + const updated = await this.prisma + .$transaction(async (transaction) => { + const result = await transaction.project.updateMany({ + where: { id: project.id, revision: input.expectedRevision, status: 'DRAFT' }, + data: { ...values, updatedById: actor.userId, revision: { increment: 1 } }, + }); + if (result.count !== 1) throw new Error('PROJECT_CONFLICT'); + await this.audit.record( + request, + { + action: 'PROJECT_UPDATED', + result: 'SUCCESS', + actor, + resourceType: 'PROJECT', + resourcePublicId: publicId, + }, + transaction, + ); + return transaction.project.findUniqueOrThrow({ + where: { id: project.id }, + include: projectInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'PROJECT_CONFLICT') throw this.conflict(); + throw error; + }); + return { project: await this.detail(updated) }; + } + + async transition( + publicId: string, + input: TransitionProjectDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const project = await this.find(publicId); + this.assertAccess(project, actor); + const fingerprint = this.fingerprint('project-transition', { + publicId, + targetStatus: input.targetStatus, + }); + const replay = await this.prisma.projectStatusHistory.findUnique({ + where: { requestId: input.clientRequestId }, + include: { project: { include: projectInclude } }, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { project: await this.detail(replay.project), idempotentReplay: true }; + } + const expected = input.targetStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED'; + if (project.status !== expected) throw this.invalidTransition(); + const updated = await this.prisma + .$transaction(async (transaction) => { + const result = await transaction.project.updateMany({ + where: { id: project.id, status: expected }, + data: { + status: input.targetStatus, + updatedById: actor.userId, + revision: { increment: 1 }, + }, + }); + if (result.count !== 1) throw new Error('PROJECT_STATE_CONFLICT'); + await transaction.projectStatusHistory.create({ + data: { + projectId: project.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + fromStatus: expected, + toStatus: input.targetStatus, + operatorUserId: actor.userId, + reasonCode: input.targetStatus === 'PUBLISHED' ? 'PUBLISHED' : 'COMPLETED', + }, + }); + await this.audit.record( + request, + { + action: 'PROJECT_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'PROJECT', + resourcePublicId: publicId, + metadata: { fromStatus: expected, toStatus: input.targetStatus }, + }, + transaction, + ); + return transaction.project.findUniqueOrThrow({ + where: { id: project.id }, + include: projectInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'PROJECT_STATE_CONFLICT') + throw this.invalidTransition(); + throw error; + }); + return { project: await this.detail(updated), idempotentReplay: false }; + } + + async replaceCases( + publicId: string, + input: ReplaceProjectCasesDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const project = await this.find(publicId); + this.assertAccess(project, actor); + if (project.status === 'COMPLETED') throw this.invalidTransition('已完成项目不能调整服务工单'); + if (new Set(input.serviceCasePublicIds).size !== input.serviceCasePublicIds.length) { + throw this.validation('服务工单不能重复'); + } + const cases = await this.prisma.serviceCase.findMany({ + where: { publicId: { in: input.serviceCasePublicIds } }, + select: { id: true, projectId: true }, + }); + if (cases.length !== input.serviceCasePublicIds.length) + throw this.validation('包含无效的服务工单'); + if ( + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) && + cases.some( + (serviceCase) => serviceCase.projectId !== null && serviceCase.projectId !== project.id, + ) + ) { + throw new ForbiddenException(); + } + const updated = await this.prisma.$transaction(async (transaction) => { + await transaction.serviceCase.updateMany({ + where: { projectId: project.id }, + data: { projectId: null }, + }); + if (cases.length) { + await transaction.serviceCase.updateMany({ + where: { id: { in: cases.map((item) => item.id) } }, + data: { projectId: project.id }, + }); + } + await this.audit.record( + request, + { + action: 'PROJECT_CASES_REPLACED', + result: 'SUCCESS', + actor, + resourceType: 'PROJECT', + resourcePublicId: publicId, + metadata: { serviceCaseCount: cases.length }, + }, + transaction, + ); + return transaction.project.findUniqueOrThrow({ + where: { id: project.id }, + include: projectInclude, + }); + }); + return { project: await this.detail(updated) }; + } + + private async prepare(input: CreateProjectDto | UpdateProjectDto, actor: AuthContext) { + const startDate = new Date(`${input.startDate}T00:00:00.000Z`); + const endDate = new Date(`${input.endDate}T00:00:00.000Z`); + if (endDate < startDate) throw this.validation('项目结束日期不能早于开始日期'); + const manager = await this.prisma.user.findFirst({ + where: { + publicId: input.managerUserPublicId, + status: 'ACTIVE', + staffProfile: { isNot: null }, + userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } }, + }, + }); + if (!manager) throw this.validation('负责人必须是有效的项目管理员'); + const cover = await this.media.requireCover(input.coverFilePublicId, 'PROJECT_COVER', actor); + return { + name: input.name.trim(), + category: input.category as never, + summary: input.summary.trim(), + content: input.content.trim(), + coverFileId: cover?.id, + managerUserId: manager.id, + startDate, + endDate, + targetPeople: input.targetPeople, + isPublic: input.isPublic, + sortOrder: input.sortOrder, + }; + } + + private async list( + where: Prisma.ProjectWhereInput, + page: number, + pageSize: number, + ): Promise { + const [total, projects] = await this.prisma.$transaction([ + this.prisma.project.count({ where }), + this.prisma.project.findMany({ + where, + include: projectInclude, + orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + return { + items: await Promise.all(projects.map((project) => this.item(project))), + page, + pageSize, + total, + }; + } + + private async find(publicId: string): Promise { + const project = await this.prisma.project.findUnique({ + where: { publicId }, + include: projectInclude, + }); + if (!project) throw this.notFound(); + return project; + } + + private assertAccess(project: StoredProject, actor: AuthContext): void { + if ( + project.managerUserId !== actor.userId && + !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) + ) { + throw new ForbiddenException(); + } + } + + private async item(project: StoredProject): Promise { + return { + publicId: project.publicId, + projectNo: project.projectNo, + name: project.name, + category: project.category, + summary: project.summary, + coverImageUrl: await this.media.publicUrl(project.coverFile), + managerUserPublicId: project.manager.publicId, + managerDisplayName: + project.manager.staffProfile?.displayName ?? project.manager.nickname ?? '项目负责人', + startDate: project.startDate.toISOString().slice(0, 10), + endDate: project.endDate.toISOString().slice(0, 10), + targetPeople: project.targetPeople, + status: project.status, + statusLabel: PROJECT_STATUS_LABELS[project.status], + isPublic: project.isPublic, + sortOrder: project.sortOrder, + revision: project.revision, + activityCount: project._count.activities, + serviceCaseCount: project._count.serviceCases, + createdAt: project.createdAt.toISOString(), + }; + } + + private async detail(project: StoredProject): Promise { + return { + ...(await this.item(project)), + content: project.content, + coverFilePublicId: project.coverFile?.publicId ?? null, + history: project.statusHistory.map((history) => ({ + status: history.toStatus, + statusLabel: PROJECT_STATUS_LABELS[history.toStatus], + reasonLabel: + ( + { CREATED: '创建项目', PUBLISHED: '发布项目', COMPLETED: '完成项目' } as Record< + string, + string + > + )[history.reasonCode] ?? '状态更新', + createdAt: history.createdAt.toISOString(), + })), + }; + } + + private fingerprint(domain: string, value: object): string { + return this.sensitive.lookupHash(domain, JSON.stringify(value)); + } + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.PROJECT_NOT_FOUND, + '未找到该公益项目', + HttpStatus.NOT_FOUND, + ); + } + private conflict(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.RESOURCE_CONFLICT, + '项目已更新,请刷新后重试', + HttpStatus.CONFLICT, + ); + } + private invalidTransition(message = '当前项目状态不能执行该操作'): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + message, + HttpStatus.CONFLICT, + ); + } + private duplicate(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '请求标识已用于其他操作', + HttpStatus.CONFLICT, + ); + } +} diff --git a/apps/api/src/participation/public-participation.controller.ts b/apps/api/src/participation/public-participation.controller.ts new file mode 100644 index 0000000..c71b8e6 --- /dev/null +++ b/apps/api/src/participation/public-participation.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Inject, Param, Query, ValidationPipe } from '@nestjs/common'; + +import type { + ActivityDetailResponse, + PaginatedActivitiesResponse, + PaginatedProjectsResponse, + ProjectDetailResponse, +} from '@lehuo/contracts'; + +import { Public } from '../common/decorators/public.decorator'; +import { ActivitiesService } from './activities.service'; +import { PublicActivityQueryDto, PublicProjectQueryDto } from './dto/participation.dto'; +import { ProjectsService } from './projects.service'; + +const strict = (expectedType: new () => object) => + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType, + }); + +@Public() +@Controller('api/v1/public') +export class PublicParticipationController { + constructor( + @Inject(ProjectsService) private readonly projects: ProjectsService, + @Inject(ActivitiesService) private readonly activities: ActivitiesService, + ) {} + + @Get('projects') + projectsList( + @Query(strict(PublicProjectQueryDto)) query: PublicProjectQueryDto, + ): Promise { + return this.projects.listPublic(query); + } + + @Get('projects/:publicId') + project(@Param('publicId') publicId: string): Promise { + return this.projects.getPublic(publicId); + } + + @Get('activities') + activitiesList( + @Query(strict(PublicActivityQueryDto)) query: PublicActivityQueryDto, + ): Promise { + return this.activities.listPublic(query); + } + + @Get('activities/:publicId') + activity(@Param('publicId') publicId: string): Promise { + return this.activities.getPublic(publicId); + } +} diff --git a/apps/api/src/participation/volunteers.service.ts b/apps/api/src/participation/volunteers.service.ts new file mode 100644 index 0000000..413b1c5 --- /dev/null +++ b/apps/api/src/participation/volunteers.service.ts @@ -0,0 +1,474 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + VOLUNTEER_SKILL_LABELS, + VOLUNTEER_STATUS_LABELS, + type AdminVolunteerDetailResponse, + type PaginatedVolunteersResponse, + type VolunteerProfile, + type VolunteerProfileResponse, + type VolunteerSkillCode, + type VolunteerStatus, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { + ApplyVolunteerDto, + RestoreVolunteerDto, + ReviewVolunteerDto, + VolunteerQueryDto, +} from './dto/participation.dto'; + +const volunteerInclude = { + skills: true, + interests: true, + statusHistory: { orderBy: { createdAt: 'desc' as const } }, + registrations: { + where: { status: 'APPROVED' as const, serviceMinutes: { not: null } }, + select: { serviceMinutes: true }, + }, +} satisfies Prisma.VolunteerInclude; + +type StoredVolunteer = Prisma.VolunteerGetPayload<{ include: typeof volunteerInclude }>; + +function maskMobile(value: string): string { + return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); +} + +@Injectable() +export class VolunteersService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + async mine(actor: AuthContext): Promise { + const volunteer = await this.prisma.volunteer.findUnique({ + where: { userId: actor.userId }, + include: volunteerInclude, + }); + return { volunteer: volunteer ? this.profile(volunteer) : null }; + } + + async apply( + input: ApplyVolunteerDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + this.assertUnique(input.skillCodes, '技能不能重复'); + this.assertUnique(input.interestCategories, '服务意向不能重复'); + const normalized = { + realName: input.realName.trim(), + mobile: input.mobile, + regionCode: input.regionCode.trim(), + availabilityNote: input.availabilityNote.trim(), + skillCodes: [...input.skillCodes].sort(), + interestCategories: [...input.interestCategories].sort(), + expectedRevision: input.expectedRevision ?? null, + privacyConsent: input.privacyConsent, + }; + const fingerprint = this.sensitive.lookupHash( + 'volunteer-application', + JSON.stringify(normalized), + ); + const existing = await this.prisma.volunteer.findUnique({ + where: { userId: actor.userId }, + include: volunteerInclude, + }); + const replay = await this.prisma.volunteerStatusHistory.findUnique({ + where: { requestId: input.clientRequestId }, + include: { volunteer: { include: volunteerInclude } }, + }); + if (replay) { + if (replay.operatorUserId !== actor.userId || replay.requestFingerprint !== fingerprint) { + throw this.duplicate(); + } + return { volunteer: this.profile(replay.volunteer), idempotentReplay: true }; + } + if (existing && input.expectedRevision !== existing.revision) { + throw this.conflict('志愿者资料已更新,请刷新后重试'); + } + if (!existing && input.expectedRevision !== undefined) { + throw this.validation('首次申请不应包含资料版本'); + } + + const identityChanged = Boolean( + existing && + (existing.realNameHash !== this.sensitive.lookupHash('volunteer-name', normalized.realName) || + existing.mobileHash !== this.sensitive.lookupHash('volunteer-mobile', normalized.mobile)), + ); + const nextStatus: VolunteerStatus = + existing?.status === 'REJECTED' || existing?.status === 'INACTIVE' + ? 'APPLIED' + : existing?.status === 'APPROVED' && identityChanged + ? 'APPLIED' + : (existing?.status ?? 'APPLIED'); + const created = await this.prisma + .$transaction(async (transaction) => { + const consent = await transaction.consent.create({ + data: { + publicId: randomUUID(), + userId: actor.userId, + consentType: 'VOLUNTEER_APPLICATION', + version: 'volunteer-application-v1', + grantedBy: 'SELF', + grantedAt: new Date(), + evidenceJson: { affirmativeAction: true }, + ipHash: request.ip ? this.sensitive.auditIpHash(request.ip) : null, + }, + }); + let volunteer; + if (existing) { + const updated = await transaction.volunteer.updateMany({ + where: { id: existing.id, revision: existing.revision }, + data: { + consentId: consent.id, + realNameEncrypted: this.sensitive.encrypt(normalized.realName), + realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName), + mobileEncrypted: this.sensitive.encrypt(normalized.mobile), + mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile), + regionCode: normalized.regionCode, + availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote), + status: nextStatus, + updatedById: actor.userId, + revision: { increment: 1 }, + }, + }); + if (updated.count !== 1) throw new Error('REVISION_CONFLICT'); + await transaction.volunteerSkill.deleteMany({ where: { volunteerId: existing.id } }); + await transaction.volunteerInterest.deleteMany({ where: { volunteerId: existing.id } }); + volunteer = await transaction.volunteer.findUniqueOrThrow({ where: { id: existing.id } }); + } else { + volunteer = await transaction.volunteer.create({ + data: { + publicId: randomUUID(), + userId: actor.userId, + consentId: consent.id, + realNameEncrypted: this.sensitive.encrypt(normalized.realName), + realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName), + mobileEncrypted: this.sensitive.encrypt(normalized.mobile), + mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile), + regionCode: normalized.regionCode, + availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote), + status: nextStatus, + createdById: actor.userId, + updatedById: actor.userId, + }, + }); + } + if (normalized.skillCodes.length) { + await transaction.volunteerSkill.createMany({ + data: normalized.skillCodes.map((skillCode) => ({ + volunteerId: volunteer.id, + skillCode, + })), + }); + } + if (normalized.interestCategories.length) { + await transaction.volunteerInterest.createMany({ + data: normalized.interestCategories.map((category) => ({ + volunteerId: volunteer.id, + category: category as never, + })), + }); + } + if (existing?.status === 'APPROVED' && nextStatus === 'APPLIED') { + const role = await transaction.role.findUniqueOrThrow({ + where: { code: 'VOLUNTEER' }, + }); + await transaction.userRole.deleteMany({ + where: { userId: actor.userId, roleId: role.id }, + }); + } + await transaction.volunteerStatusHistory.create({ + data: { + volunteerId: volunteer.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + fromStatus: existing?.status, + toStatus: nextStatus, + operatorUserId: actor.userId, + reasonCode: existing + ? nextStatus === 'APPLIED' + ? identityChanged + ? 'IDENTITY_CHANGED' + : 'REAPPLIED' + : 'PROFILE_UPDATED' + : 'APPLIED', + }, + }); + await this.audit.record( + request, + { + action: existing ? 'VOLUNTEER_PROFILE_UPDATED' : 'VOLUNTEER_APPLICATION_CREATED', + result: 'SUCCESS', + actor, + resourceType: 'VOLUNTEER', + resourcePublicId: volunteer.publicId, + metadata: { status: nextStatus }, + }, + transaction, + ); + return transaction.volunteer.findUniqueOrThrow({ + where: { id: volunteer.id }, + include: volunteerInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'REVISION_CONFLICT') { + throw this.conflict('志愿者资料已更新,请刷新后重试'); + } + throw error; + }); + return { volunteer: this.profile(created), idempotentReplay: false }; + } + + async list(query: VolunteerQueryDto): Promise { + const where = { + ...(query.status ? { status: query.status as never } : {}), + ...(query.regionCode ? { regionCode: query.regionCode } : {}), + }; + const [total, volunteers] = await this.prisma.$transaction([ + this.prisma.volunteer.count({ where }), + this.prisma.volunteer.findMany({ + where, + include: volunteerInclude, + orderBy: { createdAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + return { + items: volunteers.map((volunteer) => { + const item = this.profile(volunteer); + return { + publicId: item.publicId, + realName: item.realName, + mobileMasked: item.mobileMasked, + regionCode: item.regionCode, + skillLabels: item.skillLabels, + interestCategories: item.interestCategories, + status: item.status, + statusLabel: item.statusLabel, + totalServiceMinutes: item.totalServiceMinutes, + completedActivityCount: item.completedActivityCount, + createdAt: item.createdAt, + }; + }), + page: query.page, + pageSize: query.pageSize, + total, + }; + } + + async get( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + const volunteer = await this.prisma.volunteer.findUnique({ + where: { publicId }, + include: volunteerInclude, + }); + if (!volunteer) throw this.notFound(); + await this.audit.record(request, { + action: 'VOLUNTEER_PROFILE_VIEWED', + result: 'SUCCESS', + actor, + resourceType: 'VOLUNTEER', + resourcePublicId: publicId, + }); + return { volunteer: this.profile(volunteer) }; + } + + review( + publicId: string, + input: ReviewVolunteerDto | RestoreVolunteerDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.changeStatus(publicId, input, actor, request); + } + + private async changeStatus( + publicId: string, + input: ReviewVolunteerDto | RestoreVolunteerDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const fingerprint = this.sensitive.lookupHash( + 'volunteer-review', + JSON.stringify({ publicId, targetStatus: input.targetStatus, note: input.note.trim() }), + ); + const replay = await this.prisma.volunteerStatusHistory.findUnique({ + where: { requestId: input.clientRequestId }, + include: { volunteer: { include: volunteerInclude } }, + }); + if (replay) { + if (replay.requestFingerprint !== fingerprint) throw this.duplicate(); + return { volunteer: this.profile(replay.volunteer) }; + } + const volunteer = await this.prisma.volunteer.findUnique({ where: { publicId } }); + if (!volunteer) throw this.notFound(); + const allowed: Record = { + APPLIED: ['APPROVED', 'REJECTED'], + APPROVED: ['SUSPENDED'], + SUSPENDED: ['APPROVED'], + }; + if (!allowed[volunteer.status]?.includes(input.targetStatus)) throw this.invalidTransition(); + const result = await this.prisma + .$transaction(async (transaction) => { + const changed = await transaction.volunteer.updateMany({ + where: { id: volunteer.id, status: volunteer.status }, + data: { + status: input.targetStatus, + approvedById: input.targetStatus === 'APPROVED' ? actor.userId : volunteer.approvedById, + approvedAt: input.targetStatus === 'APPROVED' ? new Date() : volunteer.approvedAt, + updatedById: actor.userId, + revision: { increment: 1 }, + }, + }); + if (changed.count !== 1) throw new Error('STATE_CONFLICT'); + const role = await transaction.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } }); + if (input.targetStatus === 'APPROVED') { + await transaction.userRole.upsert({ + where: { userId_roleId: { userId: volunteer.userId, roleId: role.id } }, + create: { userId: volunteer.userId, roleId: role.id, assignedBy: actor.userId }, + update: { assignedBy: actor.userId, assignedAt: new Date() }, + }); + } else { + await transaction.userRole.deleteMany({ + where: { userId: volunteer.userId, roleId: role.id }, + }); + } + await transaction.volunteerStatusHistory.create({ + data: { + volunteerId: volunteer.id, + requestId: input.clientRequestId, + requestFingerprint: fingerprint, + fromStatus: volunteer.status, + toStatus: input.targetStatus, + operatorUserId: actor.userId, + reasonCode: `REVIEW_${input.targetStatus}`, + noteEncrypted: this.sensitive.encrypt(input.note.trim()), + }, + }); + await this.audit.record( + request, + { + action: 'VOLUNTEER_STATUS_CHANGED', + result: 'SUCCESS', + actor, + resourceType: 'VOLUNTEER', + resourcePublicId: publicId, + metadata: { fromStatus: volunteer.status, toStatus: input.targetStatus }, + }, + transaction, + ); + return transaction.volunteer.findUniqueOrThrow({ + where: { id: volunteer.id }, + include: volunteerInclude, + }); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message === 'STATE_CONFLICT') + throw this.invalidTransition(); + throw error; + }); + return { volunteer: this.profile(result) }; + } + + private profile(volunteer: StoredVolunteer): VolunteerProfile { + const mobile = this.sensitive.decrypt(volunteer.mobileEncrypted); + const serviceMinutes = volunteer.registrations.reduce( + (sum, item) => sum + (item.serviceMinutes ?? 0), + 0, + ); + return { + publicId: volunteer.publicId, + realName: this.sensitive.decrypt(volunteer.realNameEncrypted), + mobile, + mobileMasked: maskMobile(mobile), + regionCode: volunteer.regionCode, + availabilityNote: this.sensitive.decrypt(volunteer.availabilityNoteEncrypted), + skillCodes: volunteer.skills.map((skill) => skill.skillCode as VolunteerSkillCode), + skillLabels: volunteer.skills.map( + (skill) => VOLUNTEER_SKILL_LABELS[skill.skillCode as VolunteerSkillCode] ?? skill.skillCode, + ), + interestCategories: volunteer.interests.map((interest) => interest.category), + status: volunteer.status, + statusLabel: VOLUNTEER_STATUS_LABELS[volunteer.status], + revision: volunteer.revision, + totalServiceMinutes: serviceMinutes, + completedActivityCount: volunteer.registrations.length, + history: volunteer.statusHistory.map((history) => ({ + status: history.toStatus, + statusLabel: VOLUNTEER_STATUS_LABELS[history.toStatus], + reasonLabel: this.reasonLabel(history.reasonCode), + note: history.noteEncrypted ? this.sensitive.decrypt(history.noteEncrypted) : null, + createdAt: history.createdAt.toISOString(), + })), + createdAt: volunteer.createdAt.toISOString(), + }; + } + + private reasonLabel(code: string): string { + return ( + ( + { + APPLIED: '提交申请', + REAPPLIED: '重新申请', + IDENTITY_CHANGED: '身份信息变更,重新审核', + PROFILE_UPDATED: '更新资料', + REVIEW_APPROVED: '审核通过', + REVIEW_REJECTED: '审核未通过', + REVIEW_SUSPENDED: '暂停志愿者资格', + } as Record + )[code] ?? '状态更新' + ); + } + + private assertUnique(values: string[], message: string): void { + if (new Set(values).size !== values.length) throw this.validation(message); + } + + private validation(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); + } + + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.VOLUNTEER_NOT_FOUND, + '未找到志愿者资料', + HttpStatus.NOT_FOUND, + ); + } + + private invalidTransition(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.INVALID_STATE_TRANSITION, + '当前状态不能执行该操作', + HttpStatus.CONFLICT, + ); + } + + private conflict(message: string): ApplicationError { + return new ApplicationError(API_ERROR_CODES.RESOURCE_CONFLICT, message, HttpStatus.CONFLICT); + } + + private duplicate(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '请求标识已用于其他操作', + HttpStatus.CONFLICT, + ); + } +} diff --git a/apps/api/src/permissions/permission.guard.ts b/apps/api/src/permissions/permission.guard.ts new file mode 100644 index 0000000..30c1d42 --- /dev/null +++ b/apps/api/src/permissions/permission.guard.ts @@ -0,0 +1,34 @@ +import { + ForbiddenException, + Inject, + Injectable, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import type { PermissionCode } from '@lehuo/contracts'; + +import { REQUIRED_PERMISSIONS_KEY } from '../common/decorators/require-permissions.decorator'; +import type { RequestContext } from '../common/request-context'; + +@Injectable() +export class PermissionGuard implements CanActivate { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(REQUIRED_PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required?.length) return true; + const request = context.switchToHttp().getRequest(); + if ( + !request.auth || + required.some((permission) => !request.auth?.permissions.includes(permission)) + ) { + throw new ForbiddenException(); + } + return true; + } +} diff --git a/apps/api/src/permissions/permissions.module.ts b/apps/api/src/permissions/permissions.module.ts new file mode 100644 index 0000000..f5346b2 --- /dev/null +++ b/apps/api/src/permissions/permissions.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { ResourcePolicyService } from './resource-policy.service'; + +@Module({ + providers: [ResourcePolicyService], + exports: [ResourcePolicyService], +}) +export class PermissionsModule {} diff --git a/apps/api/src/permissions/resource-policy.service.spec.ts b/apps/api/src/permissions/resource-policy.service.spec.ts new file mode 100644 index 0000000..42fc509 --- /dev/null +++ b/apps/api/src/permissions/resource-policy.service.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { PERMISSION_CODES } from '@lehuo/contracts'; + +import type { AuthContext } from '../common/request-context'; +import { ResourcePolicyService } from './resource-policy.service'; + +function actor(userId: bigint, permissions: AuthContext['permissions'] = []): AuthContext { + return { + userId, + userPublicId: '37c96323-8d3d-4529-b143-5fa245708fbb', + status: 'ACTIVE', + userType: 'STAFF', + displayName: '测试工作人员', + roles: ['STAFF'], + permissions, + sessionId: 1n, + channel: 'ADMIN', + rawToken: 'test-token', + }; +} + +describe('ResourcePolicyService', () => { + const service = new ResourcePolicyService(); + + it('allows owners to access their own resource', () => { + expect( + service.canAccessOwnedResource(actor(10n), 10n, PERMISSION_CODES.RESOURCES_ACCESS_ALL), + ).toBe(true); + }); + + it('denies a different owner without elevated permission', () => { + expect( + service.canAccessOwnedResource(actor(10n), 11n, PERMISSION_CODES.RESOURCES_ACCESS_ALL), + ).toBe(false); + }); + + it('allows explicitly elevated cross-resource access', () => { + expect( + service.canAccessOwnedResource( + actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]), + 11n, + PERMISSION_CODES.RESOURCES_ACCESS_ALL, + ), + ).toBe(true); + }); + + it('enforces strict ownership for public self-service resources', () => { + expect(() => + service.assertResourceOwner(actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]), 11n), + ).toThrow(); + expect(() => service.assertResourceOwner(actor(10n), 10n)).not.toThrow(); + }); +}); diff --git a/apps/api/src/permissions/resource-policy.service.ts b/apps/api/src/permissions/resource-policy.service.ts new file mode 100644 index 0000000..0be6109 --- /dev/null +++ b/apps/api/src/permissions/resource-policy.service.ts @@ -0,0 +1,32 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; + +import type { PermissionCode } from '@lehuo/contracts'; + +import type { AuthContext } from '../common/request-context'; + +@Injectable() +export class ResourcePolicyService { + assertResourceOwner(actor: AuthContext, ownerUserId: bigint): void { + if (actor.userId !== ownerUserId) { + throw new ForbiddenException(); + } + } + + canAccessOwnedResource( + actor: AuthContext, + ownerUserId: bigint, + elevatedPermission: PermissionCode, + ): boolean { + return actor.userId === ownerUserId || actor.permissions.includes(elevatedPermission); + } + + assertOwnedResource( + actor: AuthContext, + ownerUserId: bigint, + elevatedPermission: PermissionCode, + ): void { + if (!this.canAccessOwnedResource(actor, ownerUserId, elevatedPermission)) { + throw new ForbiddenException(); + } + } +} diff --git a/apps/api/src/scripts/bootstrap-admin.ts b/apps/api/src/scripts/bootstrap-admin.ts new file mode 100644 index 0000000..a414b81 --- /dev/null +++ b/apps/api/src/scripts/bootstrap-admin.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; + +import { NestFactory } from '@nestjs/core'; + +import { AppModule } from '../app.module'; +import { BootstrapAdminService } from '../users/bootstrap-admin.service'; + +async function main(): Promise { + const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); + try { + const publicId = await app.get(BootstrapAdminService).createInitialAdmin(); + process.stdout.write(`Initial administrator created: ${publicId}\n`); + } finally { + await app.close(); + } +} + +void main(); diff --git a/apps/api/src/security/security.module.ts b/apps/api/src/security/security.module.ts new file mode 100644 index 0000000..a026143 --- /dev/null +++ b/apps/api/src/security/security.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { SensitiveDataService } from './sensitive-data.service'; + +@Global() +@Module({ + providers: [SensitiveDataService], + exports: [SensitiveDataService], +}) +export class SecurityModule {} diff --git a/apps/api/src/security/sensitive-data.service.spec.ts b/apps/api/src/security/sensitive-data.service.spec.ts new file mode 100644 index 0000000..d416184 --- /dev/null +++ b/apps/api/src/security/sensitive-data.service.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppConfigService } from '../config/app-config.service'; +import { SensitiveDataService } from './sensitive-data.service'; + +const config = { + fieldEncryptionKey: Buffer.alloc(32, 3), + sessionTokenPepper: 'test-session-token-pepper', + auditIpHashKey: 'test-audit-ip-hash-key', +} as AppConfigService; + +describe('SensitiveDataService', () => { + it('encrypts sensitive values with randomized authenticated ciphertext', () => { + const service = new SensitiveDataService(config); + const first = service.encrypt('sensitive-openid'); + const second = service.encrypt('sensitive-openid'); + + expect(first).not.toContain('sensitive-openid'); + expect(first).not.toBe(second); + expect(service.decrypt(first)).toBe('sensitive-openid'); + }); + + it('creates stable domain-separated lookup hashes', () => { + const service = new SensitiveDataService(config); + expect(service.lookupHash('openid', 'value')).toBe(service.lookupHash('openid', 'value')); + expect(service.lookupHash('openid', 'value')).not.toBe(service.lookupHash('unionid', 'value')); + }); +}); diff --git a/apps/api/src/security/sensitive-data.service.ts b/apps/api/src/security/sensitive-data.service.ts new file mode 100644 index 0000000..c42c6e7 --- /dev/null +++ b/apps/api/src/security/sensitive-data.service.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'; + +import { AppConfigService } from '../config/app-config.service'; + +@Injectable() +export class SensitiveDataService { + constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {} + + encrypt(value: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.config.fieldEncryptionKey, iv); + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [ + 'v1', + iv.toString('base64url'), + tag.toString('base64url'), + ciphertext.toString('base64url'), + ].join('.'); + } + + decrypt(value: string): string { + const [version, ivValue, tagValue, ciphertextValue] = value.split('.'); + if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue) { + throw new Error('Unsupported encrypted value format'); + } + const decipher = createDecipheriv( + 'aes-256-gcm', + this.config.fieldEncryptionKey, + Buffer.from(ivValue, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(tagValue, 'base64url')); + return Buffer.concat([ + decipher.update(Buffer.from(ciphertextValue, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } + + lookupHash(domain: string, value: string): string { + return createHmac('sha256', this.config.fieldEncryptionKey) + .update(`${domain}\0${value}`) + .digest('hex'); + } + + sessionTokenHash(token: string): string { + return createHmac('sha256', this.config.sessionTokenPepper).update(token).digest('hex'); + } + + auditIpHash(ip: string): string { + return createHmac('sha256', this.config.auditIpHashKey).update(ip).digest('hex'); + } +} diff --git a/apps/api/src/service-applications/admin-service-applications.controller.ts b/apps/api/src/service-applications/admin-service-applications.controller.ts new file mode 100644 index 0000000..c31d3f5 --- /dev/null +++ b/apps/api/src/service-applications/admin-service-applications.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Inject, Param, Query, Req, ValidationPipe } from '@nestjs/common'; + +import { + PERMISSION_CODES, + type PaginatedServiceApplicationsResponse, + type ServiceApplicationDetailResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { AdminServiceApplicationQueryDto } from './dto/service-application-query.dto'; +import { ServiceApplicationsService } from './service-applications.service'; + +@Controller('api/v1/admin/service-applications') +@RequireSessionChannel(SessionChannel.ADMIN) +@RequirePermissions(PERMISSION_CODES.APPLICATIONS_READ) +export class AdminServiceApplicationsController { + constructor( + @Inject(ServiceApplicationsService) + private readonly applications: ServiceApplicationsService, + ) {} + + @Get() + list( + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: AdminServiceApplicationQueryDto, + }), + ) + query: AdminServiceApplicationQueryDto, + ): Promise { + return this.applications.listAdmin(query); + } + + @Get(':publicId') + get( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.applications.getAdmin(publicId, actor, request); + } +} diff --git a/apps/api/src/service-applications/dto/create-service-application.dto.ts b/apps/api/src/service-applications/dto/create-service-application.dto.ts new file mode 100644 index 0000000..bd4d9c9 --- /dev/null +++ b/apps/api/src/service-applications/dto/create-service-application.dto.ts @@ -0,0 +1,63 @@ +import { + Equals, + IsBoolean, + IsIn, + IsOptional, + IsString, + IsUUID, + Length, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +import { + APPLICATION_APPLY_MODES, + SERVICE_CATEGORIES, + type ApplicationApplyMode, + type CreateServiceApplicationRequest, + type ServiceCategory, +} from '@lehuo/contracts'; + +export class CreateServiceApplicationDto implements CreateServiceApplicationRequest { + @IsUUID('4') + clientRequestId!: string; + + @IsIn(SERVICE_CATEGORIES) + serviceCategory!: ServiceCategory; + + @IsIn(APPLICATION_APPLY_MODES) + applyMode!: ApplicationApplyMode; + + @IsString() + @Length(1, 50) + beneficiaryName!: string; + + @Matches(/^1[3-9]\d{9}$/, { message: 'contactMobile must be a valid mobile number' }) + contactMobile!: string; + + @IsString() + @Length(2, 100) + regionCode!: string; + + @IsString() + @MinLength(10) + @MaxLength(1000) + description!: string; + + @IsOptional() + @IsString() + @Length(2, 100) + preferredContactTime?: string; + + @IsOptional() + @IsBoolean() + needsHomeVisit?: boolean; + + @IsOptional() + @IsBoolean() + mobilityDifficulty?: boolean; + + @Equals(true, { message: 'privacyConsent must be accepted' }) + privacyConsent!: true; +} diff --git a/apps/api/src/service-applications/dto/service-application-query.dto.ts b/apps/api/src/service-applications/dto/service-application-query.dto.ts new file mode 100644 index 0000000..3a79ccd --- /dev/null +++ b/apps/api/src/service-applications/dto/service-application-query.dto.ts @@ -0,0 +1,39 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +import { + SERVICE_APPLICATION_STATUSES, + SERVICE_CATEGORIES, + type ServiceApplicationStatus, + type ServiceCategory, +} from '@lehuo/contracts'; + +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 20; +} + +export class AdminServiceApplicationQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + @MaxLength(32) + applicationNo?: string; + + @IsOptional() + @IsIn(SERVICE_CATEGORIES) + serviceCategory?: ServiceCategory; + + @IsOptional() + @IsIn(SERVICE_APPLICATION_STATUSES) + status?: ServiceApplicationStatus; +} diff --git a/apps/api/src/service-applications/service-application.repository.ts b/apps/api/src/service-applications/service-application.repository.ts new file mode 100644 index 0000000..2752caf --- /dev/null +++ b/apps/api/src/service-applications/service-application.repository.ts @@ -0,0 +1,208 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { + ApplicationApplyMode, + ServiceApplicationStatus, + ServiceCategory, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; +import type { Prisma } from '../generated/prisma/client'; +import { NotificationsService } from '../content-notifications/notifications.service'; + +const detailInclude = { + statusHistory: { orderBy: { createdAt: 'asc' as const } }, +} satisfies Prisma.ServiceApplicationInclude; + +export type StoredServiceApplication = Prisma.ServiceApplicationGetPayload<{ + include: typeof detailInclude; +}>; + +export interface CreateStoredServiceApplicationInput { + publicId: string; + applicationNo: string; + applicantUserId: bigint; + clientRequestId: string; + requestFingerprint: string; + applyMode: ApplicationApplyMode; + serviceCategory: ServiceCategory; + beneficiaryNameEncrypted: string; + contactMobileEncrypted: string; + contactMobileHash: string; + regionCode: string; + descriptionEncrypted: string; + preferredContactTime?: string; + needsHomeVisit?: boolean; + mobilityDifficulty?: boolean; + submittedAt: Date; + consentPublicId: string; + consentIpHash: string | null; +} + +export interface AdminApplicationFilters { + applicationNo?: string; + serviceCategory?: ServiceCategory; + status?: ServiceApplicationStatus; +} + +@Injectable() +export class ServiceApplicationRepository { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + @Inject(NotificationsService) private readonly notifications: NotificationsService, + ) {} + + findByIdempotency( + applicantUserId: bigint, + clientRequestId: string, + ): Promise { + return this.prisma.serviceApplication.findUnique({ + where: { applicantUserId_clientRequestId: { applicantUserId, clientRequestId } }, + include: detailInclude, + }); + } + + async createWithInitialHistory( + input: CreateStoredServiceApplicationInput, + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction(async (transaction) => { + const consent = await transaction.consent.create({ + data: { + publicId: input.consentPublicId, + userId: input.applicantUserId, + consentType: 'SERVICE_APPLICATION', + version: 'service-application-v1', + grantedBy: input.applyMode === 'SELF' ? 'SELF' : 'APPLICANT', + grantedAt: input.submittedAt, + evidenceJson: { + source: 'WECHAT_MINIPROGRAM', + confirmationMethod: 'EXPLICIT_CHECKBOX', + }, + ipHash: input.consentIpHash, + }, + }); + const application = await transaction.serviceApplication.create({ + data: { + publicId: input.publicId, + applicationNo: input.applicationNo, + applicantUserId: input.applicantUserId, + consentId: consent.id, + clientRequestId: input.clientRequestId, + requestFingerprint: input.requestFingerprint, + applyMode: input.applyMode, + serviceCategory: input.serviceCategory, + beneficiaryNameEncrypted: input.beneficiaryNameEncrypted, + contactMobileEncrypted: input.contactMobileEncrypted, + contactMobileHash: input.contactMobileHash, + regionCode: input.regionCode, + descriptionEncrypted: input.descriptionEncrypted, + preferredContactTime: input.preferredContactTime, + needsHomeVisit: input.needsHomeVisit, + mobilityDifficulty: input.mobilityDifficulty, + status: 'SUBMITTED', + source: 'WECHAT_MINIPROGRAM', + submittedAt: input.submittedAt, + }, + }); + await transaction.serviceApplicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: null, + toStatus: 'SUBMITTED', + operatorUserId: actor.userId, + reasonCode: 'APPLICATION_SUBMITTED', + createdAt: input.submittedAt, + }, + }); + await this.notifications.create(transaction, { + userId: input.applicantUserId, + eventKey: `application:${input.publicId}:submitted`, + type: 'APPLICATION_SUBMITTED', + title: '服务申请已提交', + body: '我们已收到您的申请,工作人员将尽快联系。', + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: input.publicId, + }); + await this.audit.record( + request, + { + action: 'SERVICE_APPLICATION_SUBMITTED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: input.publicId, + metadata: { + serviceCategory: input.serviceCategory, + applyMode: input.applyMode, + }, + }, + transaction, + ); + return transaction.serviceApplication.findUniqueOrThrow({ + where: { id: application.id }, + include: detailInclude, + }); + }); + } + + findOwner(publicId: string): Promise<{ applicantUserId: bigint } | null> { + return this.prisma.serviceApplication.findUnique({ + where: { publicId }, + select: { applicantUserId: true }, + }); + } + + findByPublicId(publicId: string): Promise { + return this.prisma.serviceApplication.findUnique({ + where: { publicId }, + include: detailInclude, + }); + } + + async listOwned( + applicantUserId: bigint, + page: number, + pageSize: number, + ): Promise<{ items: StoredServiceApplication[]; total: number }> { + const where = { applicantUserId } satisfies Prisma.ServiceApplicationWhereInput; + const [items, total] = await this.prisma.$transaction([ + this.prisma.serviceApplication.findMany({ + where, + include: detailInclude, + orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.serviceApplication.count({ where }), + ]); + return { items, total }; + } + + async listAdmin( + filters: AdminApplicationFilters, + page: number, + pageSize: number, + ): Promise<{ items: StoredServiceApplication[]; total: number }> { + const where: Prisma.ServiceApplicationWhereInput = { + ...(filters.applicationNo ? { applicationNo: { startsWith: filters.applicationNo } } : {}), + ...(filters.serviceCategory ? { serviceCategory: filters.serviceCategory } : {}), + ...(filters.status ? { status: filters.status } : {}), + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.serviceApplication.findMany({ + where, + include: detailInclude, + orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.serviceApplication.count({ where }), + ]); + return { items, total }; + } +} diff --git a/apps/api/src/service-applications/service-applications.controller.ts b/apps/api/src/service-applications/service-applications.controller.ts new file mode 100644 index 0000000..a9ce2f1 --- /dev/null +++ b/apps/api/src/service-applications/service-applications.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + Query, + Req, + ValidationPipe, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; + +import type { + CreateServiceApplicationResponse, + PaginatedServiceApplicationsResponse, + ServiceApplicationDetailResponse, +} from '@lehuo/contracts'; + +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { CreateServiceApplicationDto } from './dto/create-service-application.dto'; +import { PaginationQueryDto } from './dto/service-application-query.dto'; +import { ServiceApplicationsService } from './service-applications.service'; + +@Controller('api/v1/service-applications') +@RequireSessionChannel(SessionChannel.WECHAT) +export class ServiceApplicationsController { + constructor( + @Inject(ServiceApplicationsService) + private readonly applications: ServiceApplicationsService, + ) {} + + @Throttle({ default: { limit: 5, ttl: 60_000 } }) + @Post() + create( + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: CreateServiceApplicationDto, + }), + ) + input: CreateServiceApplicationDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.applications.create(input, actor, request); + } + + @Get() + listMine( + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: PaginationQueryDto, + }), + ) + query: PaginationQueryDto, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.applications.listMine(actor, query.page, query.pageSize); + } + + @Get(':publicId') + getMine( + @Param('publicId') publicId: string, + @CurrentAuth() actor: AuthContext, + ): Promise { + return this.applications.getMine(publicId, actor); + } +} diff --git a/apps/api/src/service-applications/service-applications.module.ts b/apps/api/src/service-applications/service-applications.module.ts new file mode 100644 index 0000000..8193d3d --- /dev/null +++ b/apps/api/src/service-applications/service-applications.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { ContentNotificationsModule } from '../content-notifications/content-notifications.module'; +import { AdminServiceApplicationsController } from './admin-service-applications.controller'; +import { ServiceApplicationRepository } from './service-application.repository'; +import { ServiceApplicationsController } from './service-applications.controller'; +import { ServiceApplicationsService } from './service-applications.service'; +import { ServiceCategoriesController } from './service-categories.controller'; + +@Module({ + imports: [AuditModule, PermissionsModule, ContentNotificationsModule], + controllers: [ + ServiceCategoriesController, + ServiceApplicationsController, + AdminServiceApplicationsController, + ], + providers: [ServiceApplicationRepository, ServiceApplicationsService], +}) +export class ServiceApplicationsModule {} diff --git a/apps/api/src/service-applications/service-applications.service.ts b/apps/api/src/service-applications/service-applications.service.ts new file mode 100644 index 0000000..82e5899 --- /dev/null +++ b/apps/api/src/service-applications/service-applications.service.ts @@ -0,0 +1,285 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { + API_ERROR_CODES, + APPLICATION_APPLY_MODE_LABELS, + SERVICE_APPLICATION_STATUS_LABELS, + SERVICE_CATEGORY_LABELS, + type CreateServiceApplicationResponse, + type PaginatedServiceApplicationsResponse, + type ServiceApplicationDetail, + type ServiceApplicationDetailResponse, + type ServiceApplicationListItem, + type ServiceCategoriesResponse, +} from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { ResourcePolicyService } from '../permissions/resource-policy.service'; +import { SensitiveDataService } from '../security/sensitive-data.service'; +import type { CreateServiceApplicationDto } from './dto/create-service-application.dto'; +import type { AdminServiceApplicationQueryDto } from './dto/service-application-query.dto'; +import { + ServiceApplicationRepository, + type StoredServiceApplication, +} from './service-application.repository'; + +const CATEGORY_DESCRIPTIONS = { + OLD_AGE: '探访陪伴、生活协助和健康关怀等为老服务', + WOMEN: '面向困境妇女的生活支持和关爱服务', + CHILD: '面向困境儿童及家庭的关爱与支持', + DISABILITY: '面向残障人士的生活支持和关爱服务', + LIFE_DIFFICULTY: '面向暂时生活困难的家庭或个人', + OTHER: '其他需要工作人员进一步了解的服务需求', +} as const; + +function maskMobile(value: string): string { + return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); +} + +function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +function applicationDatePrefix(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}${value.month}${value.day}`; +} + +@Injectable() +export class ServiceApplicationsService { + constructor( + @Inject(ServiceApplicationRepository) + private readonly repository: ServiceApplicationRepository, + @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, + @Inject(ResourcePolicyService) private readonly policy: ResourcePolicyService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + categories(): ServiceCategoriesResponse { + return { + items: Object.entries(SERVICE_CATEGORY_LABELS).map(([code, label]) => ({ + code: code as keyof typeof SERVICE_CATEGORY_LABELS, + label, + description: CATEGORY_DESCRIPTIONS[code as keyof typeof CATEGORY_DESCRIPTIONS], + })), + }; + } + + async create( + input: CreateServiceApplicationDto, + actor: AuthContext, + request: RequestContext, + ): Promise { + const normalized = this.normalizeInput(input); + const fingerprint = this.fingerprint(normalized); + const previous = await this.repository.findByIdempotency(actor.userId, input.clientRequestId); + if (previous) return this.replay(previous, fingerprint); + + const submittedAt = new Date(); + try { + const created = await this.repository.createWithInitialHistory( + { + publicId: randomUUID(), + applicationNo: `LH${applicationDatePrefix(submittedAt)}${randomBytes(5).toString('hex').toUpperCase()}`, + applicantUserId: actor.userId, + clientRequestId: input.clientRequestId, + requestFingerprint: fingerprint, + applyMode: normalized.applyMode, + serviceCategory: normalized.serviceCategory, + beneficiaryNameEncrypted: this.sensitiveData.encrypt(normalized.beneficiaryName), + contactMobileEncrypted: this.sensitiveData.encrypt(normalized.contactMobile), + contactMobileHash: this.sensitiveData.lookupHash( + 'service-application-mobile', + normalized.contactMobile, + ), + regionCode: normalized.regionCode, + descriptionEncrypted: this.sensitiveData.encrypt(normalized.description), + ...(normalized.preferredContactTime + ? { preferredContactTime: normalized.preferredContactTime } + : {}), + ...(normalized.needsHomeVisit === undefined + ? {} + : { needsHomeVisit: normalized.needsHomeVisit }), + ...(normalized.mobilityDifficulty === undefined + ? {} + : { mobilityDifficulty: normalized.mobilityDifficulty }), + submittedAt, + consentPublicId: randomUUID(), + consentIpHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null, + }, + actor, + request, + ); + return { application: this.toDetail(created), idempotentReplay: false }; + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + const concurrent = await this.repository.findByIdempotency( + actor.userId, + input.clientRequestId, + ); + if (!concurrent) throw error; + return this.replay(concurrent, fingerprint); + } + } + + async listMine( + actor: AuthContext, + page: number, + pageSize: number, + ): Promise { + const result = await this.repository.listOwned(actor.userId, page, pageSize); + return { + items: result.items.map((item) => this.toListItem(item)), + page, + pageSize, + total: result.total, + }; + } + + async getMine(publicId: string, actor: AuthContext): Promise { + const owner = await this.repository.findOwner(publicId); + if (!owner) throw this.notFound(); + this.policy.assertResourceOwner(actor, owner.applicantUserId); + const application = await this.repository.findByPublicId(publicId); + if (!application) throw this.notFound(); + return { application: this.toDetail(application) }; + } + + async listAdmin( + query: AdminServiceApplicationQueryDto, + ): Promise { + const result = await this.repository.listAdmin( + { + ...(query.applicationNo?.trim() + ? { applicationNo: query.applicationNo.trim().toUpperCase() } + : {}), + ...(query.serviceCategory ? { serviceCategory: query.serviceCategory } : {}), + ...(query.status ? { status: query.status } : {}), + }, + query.page, + query.pageSize, + ); + return { + items: result.items.map((item) => this.toListItem(item)), + page: query.page, + pageSize: query.pageSize, + total: result.total, + }; + } + + async getAdmin( + publicId: string, + actor: AuthContext, + request: RequestContext, + ): Promise { + const application = await this.repository.findByPublicId(publicId); + if (!application) throw this.notFound(); + await this.audit.record(request, { + action: 'SERVICE_APPLICATION_DETAIL_VIEWED', + result: 'SUCCESS', + actor, + resourceType: 'SERVICE_APPLICATION', + resourcePublicId: application.publicId, + }); + return { application: this.toDetail(application) }; + } + + private normalizeInput(input: CreateServiceApplicationDto) { + return { + clientRequestId: input.clientRequestId, + serviceCategory: input.serviceCategory, + applyMode: input.applyMode, + beneficiaryName: input.beneficiaryName.trim(), + contactMobile: input.contactMobile.trim(), + regionCode: input.regionCode.trim(), + description: input.description.trim(), + preferredContactTime: input.preferredContactTime?.trim() || undefined, + needsHomeVisit: input.needsHomeVisit, + mobilityDifficulty: input.mobilityDifficulty, + }; + } + + private fingerprint(input: ReturnType): string { + return this.sensitiveData.lookupHash( + 'service-application-request', + JSON.stringify([ + input.serviceCategory, + input.applyMode, + input.beneficiaryName, + input.contactMobile, + input.regionCode, + input.description, + input.preferredContactTime ?? null, + input.needsHomeVisit ?? null, + input.mobilityDifficulty ?? null, + ]), + ); + } + + private replay( + application: StoredServiceApplication, + fingerprint: string, + ): CreateServiceApplicationResponse { + if (application.requestFingerprint !== fingerprint) { + throw new ApplicationError( + API_ERROR_CODES.DUPLICATE_REQUEST, + '本次提交标识已用于其他内容,请返回后重新提交', + HttpStatus.CONFLICT, + ); + } + return { application: this.toDetail(application), idempotentReplay: true }; + } + + private toListItem(application: StoredServiceApplication): ServiceApplicationListItem { + const contactMobile = this.sensitiveData.decrypt(application.contactMobileEncrypted); + return { + publicId: application.publicId, + applicationNo: application.applicationNo, + serviceCategory: application.serviceCategory, + serviceCategoryLabel: SERVICE_CATEGORY_LABELS[application.serviceCategory], + applyMode: application.applyMode, + applyModeLabel: APPLICATION_APPLY_MODE_LABELS[application.applyMode], + beneficiaryName: this.sensitiveData.decrypt(application.beneficiaryNameEncrypted), + contactMobileMasked: maskMobile(contactMobile), + regionCode: application.regionCode, + status: application.status, + statusLabel: SERVICE_APPLICATION_STATUS_LABELS[application.status], + submittedAt: application.submittedAt.toISOString(), + }; + } + + private toDetail(application: StoredServiceApplication): ServiceApplicationDetail { + return { + ...this.toListItem(application), + contactMobile: this.sensitiveData.decrypt(application.contactMobileEncrypted), + description: this.sensitiveData.decrypt(application.descriptionEncrypted), + preferredContactTime: application.preferredContactTime, + needsHomeVisit: application.needsHomeVisit, + mobilityDifficulty: application.mobilityDifficulty, + history: application.statusHistory.map((history) => ({ + status: history.toStatus, + statusLabel: SERVICE_APPLICATION_STATUS_LABELS[history.toStatus], + reasonLabel: + history.reasonCode === 'APPLICATION_SUBMITTED' ? '我们已收到您的服务申请' : '状态已更新', + createdAt: history.createdAt.toISOString(), + })), + }; + } + + private notFound(): ApplicationError { + return new ApplicationError( + API_ERROR_CODES.APPLICATION_NOT_FOUND, + '未找到该服务申请', + HttpStatus.NOT_FOUND, + ); + } +} diff --git a/apps/api/src/service-applications/service-categories.controller.ts b/apps/api/src/service-applications/service-categories.controller.ts new file mode 100644 index 0000000..3bf51e3 --- /dev/null +++ b/apps/api/src/service-applications/service-categories.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Inject } from '@nestjs/common'; + +import type { ServiceCategoriesResponse } from '@lehuo/contracts'; + +import { Public } from '../common/decorators/public.decorator'; +import { ServiceApplicationsService } from './service-applications.service'; + +@Controller('api/v1/service-categories') +export class ServiceCategoriesController { + constructor( + @Inject(ServiceApplicationsService) + private readonly applications: ServiceApplicationsService, + ) {} + + @Public() + @Get() + list(): ServiceCategoriesResponse { + return this.applications.categories(); + } +} diff --git a/apps/api/src/statistics/statistics.controller.ts b/apps/api/src/statistics/statistics.controller.ts new file mode 100644 index 0000000..447024d --- /dev/null +++ b/apps/api/src/statistics/statistics.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Header, Inject, Query, Req, ValidationPipe } from '@nestjs/common'; +import { PERMISSION_CODES, type StatisticsOverview } from '@lehuo/contracts'; +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RawResponse } from '../common/decorators/raw-response.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { AuditService } from '../audit/audit.service'; +import { StatisticsQueryDto } from './statistics.dto'; +import { StatisticsService } from './statistics.service'; +const strict = new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + expectedType: StatisticsQueryDto, +}); +@Controller('api/v1/admin/statistics') +@RequireSessionChannel(SessionChannel.ADMIN) +export class StatisticsController { + constructor( + @Inject(StatisticsService) private readonly service: StatisticsService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + @Get('overview') @RequirePermissions(PERMISSION_CODES.STATISTICS_READ) overview( + @Query(strict) q: StatisticsQueryDto, + ): Promise { + return this.service.overview(q); + } + @Get('export.csv') + @RawResponse() + @Header('content-type', 'text/csv; charset=utf-8') + @Header('content-disposition', 'attachment; filename="statistics-summary.csv"') + @RequirePermissions(PERMISSION_CODES.EXPORTS_CREATE) + async export( + @Query(strict) q: StatisticsQueryDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ) { + const s = await this.service.overview(q); + await this.audit.record(request, { + action: 'STATISTICS_EXPORTED', + result: 'SUCCESS', + actor, + resourceType: 'STATISTICS_EXPORT', + metadata: { startDate: s.period.startDate, endDate: s.period.endDate, masked: true }, + }); + const rows = [ + ['指标', '数值'], + ['申请数量', s.applicationCount], + ['已受理数量', s.acceptedCount], + ['已完成服务', s.completedServiceCount], + ['服务人次', s.servicePersonTimes], + ['独立服务对象', s.uniqueBeneficiaryCount], + ['志愿者数量', s.volunteerCount], + ['活动数量', s.activityCount], + ['志愿服务分钟', s.volunteerMinutes], + ]; + return '\uFEFF' + rows.map((r) => r.join(',')).join('\r\n'); + } +} diff --git a/apps/api/src/statistics/statistics.dto.ts b/apps/api/src/statistics/statistics.dto.ts new file mode 100644 index 0000000..4f45613 --- /dev/null +++ b/apps/api/src/statistics/statistics.dto.ts @@ -0,0 +1,6 @@ +import { IsOptional, Matches } from 'class-validator'; +const DATE = /^\d{4}-\d{2}-\d{2}$/; +export class StatisticsQueryDto { + @IsOptional() @Matches(DATE) startDate?: string; + @IsOptional() @Matches(DATE) endDate?: string; +} diff --git a/apps/api/src/statistics/statistics.module.ts b/apps/api/src/statistics/statistics.module.ts new file mode 100644 index 0000000..d1dd037 --- /dev/null +++ b/apps/api/src/statistics/statistics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { StatisticsController } from './statistics.controller'; +import { StatisticsService } from './statistics.service'; +@Module({ + imports: [AuditModule], + controllers: [StatisticsController], + providers: [StatisticsService], +}) +export class StatisticsModule {} diff --git a/apps/api/src/statistics/statistics.service.ts b/apps/api/src/statistics/statistics.service.ts new file mode 100644 index 0000000..3c073c3 --- /dev/null +++ b/apps/api/src/statistics/statistics.service.ts @@ -0,0 +1,87 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { API_ERROR_CODES, type StatisticsOverview } from '@lehuo/contracts'; +import { ApplicationError } from '../common/application-error'; +import { PrismaService } from '../database/prisma.service'; +import type { StatisticsQueryDto } from './statistics.dto'; +type CountRow = { count: bigint }; +type CategoryRow = { category: string; count: bigint }; +type MonthRow = { month: string; applications: bigint; completed: bigint }; +@Injectable() +export class StatisticsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + async overview(q: StatisticsQueryDto): Promise { + const end = q.endDate ? new Date(`${q.endDate}T23:59:59.999Z`) : new Date(); + const start = q.startDate + ? new Date(`${q.startDate}T00:00:00.000Z`) + : new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - 11, 1)); + if (start > end || end.getTime() - start.getTime() > 366 * 86400000) + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '统计日期范围必须有效且不超过 366 天', + HttpStatus.BAD_REQUEST, + ); + const [ + applications, + accepted, + completed, + personTimes, + beneficiaries, + volunteers, + activities, + minutes, + byCategory, + monthly, + ] = await Promise.all([ + this + .count`SELECT COUNT(*) count FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COUNT(*) count FROM service_applications WHERE status IN ('ACCEPTED','SERVICE_PLANNED','IN_SERVICE','SERVICE_COMPLETED','FOLLOW_UP_PENDING','CLOSED') AND submitted_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COUNT(*) count FROM service_cases WHERE status='SERVICE_COMPLETED' AND completed_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COUNT(*) count FROM service_records WHERE service_start_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COUNT(DISTINCT beneficiary_id) count FROM service_cases WHERE status='SERVICE_COMPLETED' AND completed_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COUNT(*) count FROM volunteers WHERE status='APPROVED' AND created_at <= ${end}`, + this + .count`SELECT COUNT(*) count FROM activities WHERE created_at BETWEEN ${start} AND ${end}`, + this + .count`SELECT COALESCE(SUM(service_minutes),0) count FROM activity_registrations WHERE service_minutes_recorded_at BETWEEN ${start} AND ${end}`, + this.prisma.$queryRaw< + CategoryRow[] + >`SELECT service_category category, COUNT(*) count FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end} GROUP BY service_category ORDER BY count DESC`, + this.prisma.$queryRaw< + MonthRow[] + >`SELECT DATE_FORMAT(m.month,'%Y-%m') month, SUM(m.applications) applications, SUM(m.completed) completed FROM (SELECT DATE_FORMAT(submitted_at,'%Y-%m-01') month,COUNT(*) applications,0 completed FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end} GROUP BY month UNION ALL SELECT DATE_FORMAT(completed_at,'%Y-%m-01') month,0 applications,COUNT(*) completed FROM service_cases WHERE completed_at BETWEEN ${start} AND ${end} GROUP BY month) m GROUP BY m.month ORDER BY m.month`, + ]); + return { + period: { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }, + applicationCount: applications, + acceptedCount: accepted, + completedServiceCount: completed, + servicePersonTimes: personTimes, + uniqueBeneficiaryCount: beneficiaries, + volunteerCount: volunteers, + activityCount: activities, + volunteerMinutes: minutes, + byCategory: byCategory.map((r) => ({ + category: r.category as never, + count: Number(r.count), + })), + monthly: monthly.map((r) => ({ + month: r.month, + applications: Number(r.applications), + completedServices: Number(r.completed), + })), + generatedAt: new Date().toISOString(), + }; + } + private async count(strings: TemplateStringsArray, ...values: unknown[]) { + const rows = await this.prisma.$queryRaw(strings, ...values); + return Number(rows[0]?.count ?? 0); + } +} diff --git a/apps/api/src/users/bootstrap-admin.service.ts b/apps/api/src/users/bootstrap-admin.service.ts new file mode 100644 index 0000000..84cb825 --- /dev/null +++ b/apps/api/src/users/bootstrap-admin.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import { PrismaService } from '../database/prisma.service'; +import { PasswordService } from '../auth/password.service'; + +@Injectable() +export class BootstrapAdminService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PasswordService) private readonly passwords: PasswordService, + ) {} + + async createInitialAdmin(): Promise { + const username = process.env.BOOTSTRAP_ADMIN_USERNAME?.trim().toLowerCase(); + const password = process.env.BOOTSTRAP_ADMIN_PASSWORD; + const displayName = process.env.BOOTSTRAP_ADMIN_DISPLAY_NAME?.trim(); + if (!username || !/^[a-z0-9._-]{3,64}$/.test(username)) { + throw new Error('BOOTSTRAP_ADMIN_USERNAME is invalid'); + } + if (!password || password.length < 12 || password.length > 128) { + throw new Error('BOOTSTRAP_ADMIN_PASSWORD must contain 12 to 128 characters'); + } + if (!displayName || displayName.length > 100) { + throw new Error('BOOTSTRAP_ADMIN_DISPLAY_NAME is invalid'); + } + const passwordHash = await this.passwords.hash(password); + return this.prisma.$transaction(async (transaction) => { + if ((await transaction.adminCredential.count()) > 0) { + throw new Error('An administrator credential already exists; bootstrap is disabled'); + } + const role = await transaction.role.findUniqueOrThrow({ where: { code: 'SUPER_ADMIN' } }); + const publicId = randomUUID(); + const user = await transaction.user.create({ + data: { + publicId, + userType: 'STAFF', + staffProfile: { create: { displayName } }, + adminCredential: { create: { username, passwordHash } }, + }, + }); + await transaction.userRole.create({ + data: { userId: user.id, roleId: role.id }, + }); + await transaction.auditLog.create({ + data: { + requestId: randomUUID(), + actorUserId: user.id, + actorRole: 'SUPER_ADMIN', + action: 'BOOTSTRAP_ADMIN_CREATED', + resourceType: 'USER', + resourcePublicId: publicId, + result: 'SUCCESS', + }, + }); + return publicId; + }); + } +} diff --git a/apps/api/src/users/dto/update-user-roles.dto.ts b/apps/api/src/users/dto/update-user-roles.dto.ts new file mode 100644 index 0000000..1b9ced7 --- /dev/null +++ b/apps/api/src/users/dto/update-user-roles.dto.ts @@ -0,0 +1,11 @@ +import { ArrayMaxSize, ArrayUnique, IsArray, IsIn } from 'class-validator'; + +import { ROLE_CODES, type RoleCode, type UpdateUserRolesRequest } from '@lehuo/contracts'; + +export class UpdateUserRolesDto implements UpdateUserRolesRequest { + @IsArray() + @ArrayUnique() + @ArrayMaxSize(ROLE_CODES.length) + @IsIn(ROLE_CODES, { each: true }) + roles!: RoleCode[]; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..6db3c34 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Inject, Param, Put, Req, UseGuards } from '@nestjs/common'; + +import { PERMISSION_CODES, type UpdateUserRolesResponse } from '@lehuo/contracts'; + +import { CurrentAuth } from '../common/decorators/current-auth.decorator'; +import { RequirePermissions } from '../common/decorators/require-permissions.decorator'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { SessionChannel } from '../generated/prisma/enums'; +import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator'; +import { AdminOriginGuard } from '../auth/admin-origin.guard'; +import { UpdateUserRolesDto } from './dto/update-user-roles.dto'; +import { UsersService } from './users.service'; + +@Controller('api/v1/admin/users') +@RequireSessionChannel(SessionChannel.ADMIN) +export class UsersController { + constructor(@Inject(UsersService) private readonly users: UsersService) {} + + @RequirePermissions(PERMISSION_CODES.ROLES_MANAGE) + @UseGuards(AdminOriginGuard) + @Put(':publicId/roles') + replaceRoles( + @Param('publicId') publicId: string, + @Body() input: UpdateUserRolesDto, + @CurrentAuth() actor: AuthContext, + @Req() request: RequestContext, + ): Promise { + return this.users.replaceRoles(publicId, input.roles, actor, request); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..a461ee2 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; +import { BootstrapAdminService } from './bootstrap-admin.service'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [AuditModule, AuthModule], + controllers: [UsersController], + providers: [UsersService, BootstrapAdminService], + exports: [BootstrapAdminService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..1308675 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,88 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; + +import { API_ERROR_CODES, type RoleCode, type UpdateUserRolesResponse } from '@lehuo/contracts'; + +import { AuditService } from '../audit/audit.service'; +import { ApplicationError } from '../common/application-error'; +import type { AuthContext, RequestContext } from '../common/request-context'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class UsersService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AuditService) private readonly audit: AuditService, + ) {} + + async replaceRoles( + userPublicId: string, + roles: RoleCode[], + actor: AuthContext, + request: RequestContext, + ): Promise { + return this.prisma.$transaction( + async (transaction) => { + const user = await transaction.user.findUnique({ + where: { publicId: userPublicId }, + include: { userRoles: { include: { role: true } } }, + }); + if (!user) { + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '未找到可管理的用户', + HttpStatus.NOT_FOUND, + ); + } + const roleRecords = await transaction.role.findMany({ where: { code: { in: roles } } }); + if (roleRecords.length !== roles.length) { + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '角色设置无效', + HttpStatus.BAD_REQUEST, + ); + } + const hadSuperAdmin = user.userRoles.some(({ role }) => role.code === 'SUPER_ADMIN'); + const keepsSuperAdmin = roles.includes('SUPER_ADMIN'); + if (hadSuperAdmin && !keepsSuperAdmin) { + const activeSuperAdmins = await transaction.userRole.count({ + where: { + role: { code: 'SUPER_ADMIN' }, + user: { status: 'ACTIVE' }, + }, + }); + if (activeSuperAdmins <= 1) { + throw new ApplicationError( + API_ERROR_CODES.VALIDATION_ERROR, + '系统必须至少保留一名可用的超级管理员', + HttpStatus.CONFLICT, + ); + } + } + await transaction.userRole.deleteMany({ where: { userId: user.id } }); + if (roleRecords.length) { + await transaction.userRole.createMany({ + data: roleRecords.map((role) => ({ + userId: user.id, + roleId: role.id, + assignedBy: actor.userId, + })), + }); + } + await this.audit.record( + request, + { + action: 'RBAC_USER_ROLES_REPLACED', + result: 'SUCCESS', + actor, + resourceType: 'USER', + resourcePublicId: user.publicId, + metadata: { roleCount: roles.length }, + }, + transaction, + ); + return { userPublicId: user.publicId, roles }; + }, + { isolationLevel: 'Serializable' }, + ); + } +} diff --git a/apps/api/test/auth.integration-spec.ts b/apps/api/test/auth.integration-spec.ts new file mode 100644 index 0000000..eedeaa1 --- /dev/null +++ b/apps/api/test/auth.integration-spec.ts @@ -0,0 +1,306 @@ +import type { INestApplication } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import type { + AdminLoginResponse, + ApiErrorResponse, + ApiSuccessResponse, + CurrentUserResponse, + RoleCode, + UpdateUserRolesResponse, + WechatLoginResponse, +} from '@lehuo/contracts'; + +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; +import { PrismaService } from '../src/database/prisma.service'; + +const ADMIN_ORIGIN = 'http://localhost:5173'; + +class TestWechatAuthAdapter implements WechatAuthAdapter { + exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { + return Promise.resolve({ + openid: `generated-openid-${code}`, + unionid: `generated-unionid-${code}`, + }); + } +} + +function responseBody(response: { body: unknown }): T { + return response.body as T; +} + +function sessionCookie(response: { headers: Record }): string { + const value = response.headers['set-cookie']; + const cookies = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) throw new Error('Admin session cookie was not set'); + return cookie.split(';')[0] ?? ''; +} + +describe('Phase 1 authentication and authorization', () => { + let app: INestApplication; + let prisma: PrismaService; + let passwords: PasswordService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(WECHAT_AUTH_ADAPTER) + .useValue(new TestWechatAuthAdapter()) + .compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + }); + + beforeEach(async () => { + await prisma.auditLog.deleteMany(); + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + await prisma.activityRegistrationHistory.deleteMany(); + await prisma.activityRegistration.deleteMany(); + await prisma.activityStatusHistory.deleteMany(); + await prisma.activity.deleteMany(); + await prisma.serviceCase.updateMany({ data: { projectId: null } }); + await prisma.projectStatusHistory.deleteMany(); + await prisma.project.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); + await prisma.followUpStatusHistory.deleteMany(); + await prisma.followUpTask.deleteMany(); + await prisma.serviceRecordFile.deleteMany(); + await prisma.serviceRecordRevision.deleteMany(); + await prisma.serviceRecord.deleteMany(); + await prisma.file.deleteMany(); + await prisma.serviceCaseStatusHistory.deleteMany(); + await prisma.caseAssignee.deleteMany(); + await prisma.serviceCase.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany(); + await prisma.beneficiaryTag.deleteMany(); + await prisma.beneficiaryContact.deleteMany(); + await prisma.volunteerStatusHistory.deleteMany(); + await prisma.volunteerSkill.deleteMany(); + await prisma.volunteerInterest.deleteMany(); + await prisma.volunteer.deleteMany(); + await prisma.consent.deleteMany(); + await prisma.beneficiaryProfile.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + await prisma.user.deleteMany(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function createStaff( + username: string, + password: string, + roleCode: RoleCode, + ): Promise<{ publicId: string }> { + const passwordHash = await passwords.hash(password); + const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); + const user = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName: '测试工作人员' } }, + adminCredential: { create: { username, passwordHash } }, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); + return { publicId: user.publicId }; + } + + async function adminLogin(username: string, password: string, fixedCookie?: string) { + let call = request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ADMIN_ORIGIN); + if (fixedCookie) call = call.set('cookie', fixedCookie); + return call.send({ username, password }); + } + + it('keeps health public while protecting current-user endpoints', async () => { + const health = await request(app.getHttpServer() as Server) + .get('/health') + .expect(200); + expect(responseBody<{ status: string }>(health).status).toBe('ok'); + + const unauthorized = await request(app.getHttpServer() as Server) + .get('/api/v1/me') + .expect(401); + const error = responseBody(unauthorized); + expect(error.error.code).toBe('AUTH_REQUIRED'); + expect(error.requestId).toBeTruthy(); + }); + + it('uses an HttpOnly cookie, rejects session fixation, and returns no admin token in JSON', async () => { + const password = 'correct horse battery staple'; + await createStaff('admin.one', password, 'ADMIN'); + const login = await adminLogin( + 'admin.one', + password, + `${ADMIN_SESSION_COOKIE}=attacker-fixed`, + ).then((response) => { + expect(response.status).toBe(201); + return response; + }); + const cookie = sessionCookie(login); + expect(cookie).not.toContain('attacker-fixed'); + const cookieHeaders = (login.headers as Record)['set-cookie'] as string[]; + expect(cookieHeaders.join(';')).toContain('HttpOnly'); + expect(cookieHeaders.join(';')).toContain('SameSite=Strict'); + const payload = responseBody>(login); + expect(payload.data.user.roles).toContain('ADMIN'); + expect(JSON.stringify(payload)).not.toContain('accessToken'); + + const me = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/auth/me') + .set('cookie', cookie) + .expect(200); + expect( + responseBody>(me).data.user.permissions, + ).toContain('admin:access'); + }); + + it('rejects cross-origin admin login requests', async () => { + await createStaff('admin.origin', 'correct horse battery staple', 'ADMIN'); + const response = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', 'https://malicious.example') + .send({ username: 'admin.origin', password: 'correct horse battery staple' }) + .expect(403); + expect(responseBody(response).error.code).toBe('FORBIDDEN'); + }); + + it('records failed login without password, username, or session data in audit metadata', async () => { + await createStaff('admin.audit', 'correct horse battery staple', 'ADMIN'); + const attemptedPassword = 'incorrect password value'; + const response = await adminLogin('admin.audit', attemptedPassword); + expect(response.status).toBe(401); + expect(responseBody(response).error.code).toBe('INVALID_CREDENTIALS'); + const logs = await prisma.auditLog.findMany({ select: { action: true, metadataJson: true } }); + const serialized = JSON.stringify(logs); + expect(serialized).not.toContain(attemptedPassword); + expect(serialized).not.toContain('admin.audit'); + expect(serialized).toContain('INVALID_CREDENTIALS'); + }); + + it('rejects a temporarily locked administrator account', async () => { + const password = 'correct horse battery staple'; + await createStaff('admin.locked', password, 'ADMIN'); + await prisma.adminCredential.update({ + where: { username: 'admin.locked' }, + data: { failedLoginCount: 5, lockedUntil: new Date(Date.now() + 60_000) }, + }); + const response = await adminLogin('admin.locked', password); + expect(response.status).toBe(429); + expect(responseBody(response).error.code).toBe('ACCOUNT_LOCKED'); + }); + + it('enforces RBAC after a valid staff credential login', async () => { + const password = 'correct horse battery staple'; + await createStaff('volunteer.login', password, 'VOLUNTEER'); + const login = await adminLogin('volunteer.login', password); + expect(login.status).toBe(201); + const cookie = sessionCookie(login); + const response = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/auth/me') + .set('cookie', cookie) + .expect(403); + expect(responseBody(response).error.code).toBe('FORBIDDEN'); + }); + + it('encrypts WeChat identity values, hashes tokens, and rotates previous sessions', async () => { + const first = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code: 'same-code' }) + .expect(201); + const firstPayload = responseBody>(first); + const identity = await prisma.wechatIdentity.findFirstOrThrow(); + expect(identity.openidCiphertext).not.toContain('generated-openid-same-code'); + expect(identity.openidHash).not.toContain('generated-openid-same-code'); + const storedSession = await prisma.authSession.findFirstOrThrow(); + expect(storedSession.tokenHash).not.toBe(firstPayload.data.accessToken); + + const second = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code: 'same-code' }) + .expect(201); + const secondPayload = responseBody>(second); + expect(secondPayload.data.accessToken).not.toBe(firstPayload.data.accessToken); + await request(app.getHttpServer() as Server) + .get('/api/v1/me') + .set('authorization', `Bearer ${firstPayload.data.accessToken}`) + .expect(401); + await request(app.getHttpServer() as Server) + .get('/api/v1/me') + .set('authorization', `Bearer ${secondPayload.data.accessToken}`) + .expect(200); + const activeSession = await prisma.authSession.findFirstOrThrow({ where: { revokedAt: null } }); + await prisma.authSession.update({ + where: { id: activeSession.id }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }); + await request(app.getHttpServer() as Server) + .get('/api/v1/me') + .set('authorization', `Bearer ${secondPayload.data.accessToken}`) + .expect(401); + }); + + it('allows an authorized admin to replace roles and audits the change', async () => { + const password = 'correct horse battery staple'; + await createStaff('role.admin', password, 'ADMIN'); + const target = await createStaff('role.target', password, 'STAFF'); + const login = await adminLogin('role.admin', password); + const cookie = sessionCookie(login); + const response = await request(app.getHttpServer() as Server) + .put(`/api/v1/admin/users/${target.publicId}/roles`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', cookie) + .send({ roles: ['PROJECT_MANAGER'] }) + .expect(200); + expect(responseBody>(response).data.roles).toEqual([ + 'PROJECT_MANAGER', + ]); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { action: 'RBAC_USER_ROLES_REPLACED' }, + }); + expect(audit.resourcePublicId).toBe(target.publicId); + }); + + it('does not allow removal of the last active super administrator', async () => { + const password = 'correct horse battery staple'; + const superAdmin = await createStaff('last.super', password, 'SUPER_ADMIN'); + const login = await adminLogin('last.super', password); + const cookie = sessionCookie(login); + const response = await request(app.getHttpServer() as Server) + .put(`/api/v1/admin/users/${superAdmin.publicId}/roles`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', cookie) + .send({ roles: ['ADMIN'] }) + .expect(409); + expect(responseBody(response).error.code).toBe('VALIDATION_ERROR'); + }); +}); diff --git a/apps/api/test/case-management.integration-spec.ts b/apps/api/test/case-management.integration-spec.ts new file mode 100644 index 0000000..795f11e --- /dev/null +++ b/apps/api/test/case-management.integration-spec.ts @@ -0,0 +1,488 @@ +import { ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import { getStorageToken, type ThrottlerStorageService } from '@nestjs/throttler'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import type { + AcceptServiceApplicationResponse, + ApiErrorResponse, + ApiSuccessResponse, + BeneficiaryDetailResponse, + CreateServiceApplicationResponse, + RoleCode, + ServiceCaseDetailResponse, + ServiceRecordResponse, + WechatLoginResponse, +} from '@lehuo/contracts'; + +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; +import { PrismaService } from '../src/database/prisma.service'; +import { + OBJECT_STORAGE_ADAPTER, + type ObjectStorageAdapter, + type StoredObjectMetadata, +} from '../src/files/object-storage.adapter'; + +const ADMIN_ORIGIN = 'http://localhost:5173'; + +class PhaseThreeWechatAdapter implements WechatAuthAdapter { + exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { + return Promise.resolve({ openid: `phase3-openid-${code}`, unionid: `phase3-unionid-${code}` }); + } +} + +class TestObjectStorageAdapter implements ObjectStorageAdapter { + readonly configured = true; + readonly providerName = 'TEST_S3'; + readonly bucket = 'test-private'; + private readonly metadata = new Map(); + + createUploadUrl( + objectKey: string, + mimeType: string, + sha256: string | undefined, + ): Promise { + this.metadata.set(objectKey, { + contentLength: 1024, + contentType: mimeType, + sha256: sha256 ?? null, + }); + return Promise.resolve(`http://storage.test/upload/${encodeURIComponent(objectKey)}`); + } + + createDownloadUrl(objectKey: string): Promise { + return Promise.resolve(`http://storage.test/download/${encodeURIComponent(objectKey)}`); + } + + headObject(objectKey: string): Promise { + return Promise.resolve(this.metadata.get(objectKey) ?? null); + } +} + +function body(response: { body: unknown }): T { + return response.body as T; +} + +function adminCookie(response: { headers: Record }): string { + const value = response.headers['set-cookie']; + const cookies = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) throw new Error('Admin session cookie was not set'); + return cookie.split(';')[0] ?? ''; +} + +describe('Phase 3 beneficiary and case management', () => { + let app: INestApplication; + let prisma: PrismaService; + let passwords: PasswordService; + let throttlerStorage: ThrottlerStorageService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(WECHAT_AUTH_ADAPTER) + .useValue(new PhaseThreeWechatAdapter()) + .overrideProvider(OBJECT_STORAGE_ADAPTER) + .useValue(new TestObjectStorageAdapter()) + .compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + throttlerStorage = app.get(getStorageToken()); + }); + + beforeEach(async () => { + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + throttlerStorage.storage.clear(); + await prisma.auditLog.deleteMany(); + await prisma.activityRegistrationHistory.deleteMany(); + await prisma.activityRegistration.deleteMany(); + await prisma.activityStatusHistory.deleteMany(); + await prisma.activity.deleteMany(); + await prisma.serviceCase.updateMany({ data: { projectId: null } }); + await prisma.projectStatusHistory.deleteMany(); + await prisma.project.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); + await prisma.followUpStatusHistory.deleteMany(); + await prisma.followUpTask.deleteMany(); + await prisma.serviceRecordFile.deleteMany(); + await prisma.serviceRecordRevision.deleteMany(); + await prisma.serviceRecord.deleteMany(); + await prisma.file.deleteMany(); + await prisma.serviceCaseStatusHistory.deleteMany(); + await prisma.caseAssignee.deleteMany(); + await prisma.serviceCase.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany(); + await prisma.beneficiaryTag.deleteMany(); + await prisma.beneficiaryContact.deleteMany(); + await prisma.volunteerStatusHistory.deleteMany(); + await prisma.volunteerSkill.deleteMany(); + await prisma.volunteerInterest.deleteMany(); + await prisma.volunteer.deleteMany(); + await prisma.consent.deleteMany(); + await prisma.beneficiaryProfile.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + await prisma.user.deleteMany(); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + async function createStaff(username: string, roleCode: RoleCode, displayName = '工作人员') { + const password = 'correct horse battery staple'; + const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); + const user = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName } }, + adminCredential: { create: { username, passwordHash: await passwords.hash(password) } }, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); + const login = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ADMIN_ORIGIN) + .send({ username, password }) + .expect(201); + return { user, cookie: adminCookie(login) }; + } + + async function wechatUser(code: string) { + const response = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code }) + .expect(201); + const result = body>(response).data; + const user = await prisma.user.findUniqueOrThrow({ where: { publicId: result.user.publicId } }); + return { user, token: result.accessToken }; + } + + async function submitApplication(token: string) { + const response = await request(app.getHttpServer() as Server) + .post('/api/v1/service-applications') + .set('authorization', `Bearer ${token}`) + .send({ + clientRequestId: crypto.randomUUID(), + serviceCategory: 'OLD_AGE', + applyMode: 'FAMILY', + beneficiaryName: '李奶奶', + contactMobile: '13812345678', + regionCode: '丹阳市开发区', + description: '老人近期行动不便,需要安排定期探访和生活协助。', + needsHomeVisit: true, + mobilityDifficulty: true, + privacyConsent: true, + }) + .expect(201); + return body>(response).data.application; + } + + async function advanceToAssessing(publicId: string, cookie: string) { + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${publicId}/transition`) + .set('cookie', cookie) + .send({ targetStatus: 'PENDING_CONTACT', reasonCode: 'CONTACT_STARTED' }) + .expect(201); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${publicId}/transition`) + .set('cookie', cookie) + .send({ targetStatus: 'ASSESSING', reasonCode: 'CONTACT_COMPLETED' }) + .expect(201); + } + + function acceptance(ownerPublicId: string, clientRequestId = crypto.randomUUID()) { + return { + clientRequestId, + beneficiary: { + mode: 'CREATE', + profile: { + displayName: '李奶奶', + gender: 'FEMALE', + birthYear: 1948, + mobile: '13812345678', + regionCode: '丹阳市开发区', + keyCareFlag: true, + isMinor: false, + contacts: [ + { + name: '李女士', + relation: '女儿', + mobile: '13912345678', + isGuardian: false, + isEmergencyContact: true, + }, + ], + tagCodes: ['ADVANCED_AGE', 'MOBILITY_LIMITED'], + }, + }, + ownerStaffPublicId: ownerPublicId, + collaboratorUserPublicIds: [], + priority: 'HIGH', + plannedStartAt: '2026-08-12T02:00:00.000Z', + serviceLocation: '丹阳市开发区某小区(详细地址)', + summary: '安排工作人员上门探访,了解生活协助需求并制定服务计划。', + }; + } + + async function createAcceptedCase() { + const applicant = await wechatUser(`applicant-${crypto.randomUUID()}`); + const application = await submitApplication(applicant.token); + const processor = await createStaff(`processor.${crypto.randomUUID()}`, 'STAFF', '受理人员'); + const owner = await createStaff(`owner.${crypto.randomUUID()}`, 'STAFF', '责任人员'); + await advanceToAssessing(application.publicId, processor.cookie); + const response = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(acceptance(owner.user.publicId)) + .expect(201); + return { + applicant, + application, + processor, + owner, + serviceCase: + body>(response).data.serviceCase, + }; + } + + it('enforces the application state machine and rejects skipped acceptance', async () => { + const applicant = await wechatUser('skip-applicant'); + const application = await submitApplication(applicant.token); + const processor = await createStaff('skip.processor', 'STAFF'); + const owner = await createStaff('skip.owner', 'STAFF'); + const response = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(acceptance(owner.user.publicId)) + .expect(409); + expect(body(response).error.code).toBe('INVALID_STATE_TRANSITION'); + expect(await prisma.serviceCase.count()).toBe(0); + }); + + it('transactionally accepts, creates an encrypted beneficiary and assigned case with histories', async () => { + const result = await createAcceptedCase(); + expect(result.serviceCase.status).toBe('ASSIGNED'); + expect(result.serviceCase.beneficiaryDisplayName).toBe('李奶奶'); + const stored = await prisma.beneficiaryProfile.findFirstOrThrow(); + expect(stored.displayNameEncrypted).not.toContain('李奶奶'); + expect(stored.mobileEncrypted).not.toContain('13812345678'); + expect((await prisma.serviceApplication.findFirstOrThrow()).status).toBe('SERVICE_PLANNED'); + expect(await prisma.serviceApplicationStatusHistory.count()).toBe(5); + expect(await prisma.serviceCaseStatusHistory.count()).toBe(1); + expect(await prisma.caseAssignee.count()).toBe(1); + expect(await prisma.auditLog.count({ where: { action: 'SERVICE_CASE_CREATED' } })).toBe(1); + }); + + it('replays identical acceptance and rejects a changed payload', async () => { + const applicant = await wechatUser('idempotent-applicant'); + const application = await submitApplication(applicant.token); + const processor = await createStaff('idempotent.processor', 'STAFF'); + const owner = await createStaff('idempotent.owner', 'STAFF'); + await advanceToAssessing(application.publicId, processor.cookie); + const requestId = crypto.randomUUID(); + const payload = acceptance(owner.user.publicId, requestId); + const first = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(payload) + .expect(201); + const second = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(payload) + .expect(201); + expect( + body>(second).data.idempotentReplay, + ).toBe(true); + expect( + body>(first).data.serviceCase.publicId, + ).toBe( + body>(second).data.serviceCase.publicId, + ); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send({ ...payload, priority: 'LOW' }) + .expect(409); + expect(await prisma.serviceCase.count()).toBe(1); + }); + + it('requires guardian consent for a minor profile', async () => { + const applicant = await wechatUser('minor-applicant'); + const application = await submitApplication(applicant.token); + const processor = await createStaff('minor.processor', 'STAFF'); + const owner = await createStaff('minor.owner', 'STAFF'); + await advanceToAssessing(application.publicId, processor.cookie); + const payload = acceptance(owner.user.publicId); + payload.beneficiary.profile.isMinor = true; + payload.beneficiary.profile.birthYear = 2016; + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(payload) + .expect(400); + payload.beneficiary.profile.contacts[0] = { + name: '李女士', + relation: '母亲', + mobile: '13912345678', + isGuardian: true, + isEmergencyContact: true, + }; + Object.assign(payload.beneficiary.profile, { guardianConsentConfirmed: true }); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) + .set('cookie', processor.cookie) + .send(payload) + .expect(201); + expect(await prisma.consent.count({ where: { consentType: 'BENEFICIARY_GUARDIAN' } })).toBe(1); + }); + + it('audits beneficiary detail and keeps list mobile values masked', async () => { + const result = await createAcceptedCase(); + const list = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/beneficiaries') + .set('cookie', result.processor.cookie) + .expect(200); + expect(JSON.stringify(list.body)).not.toContain('13812345678'); + const detail = await request(app.getHttpServer() as Server) + .get(`/api/v1/admin/beneficiaries/${result.serviceCase.beneficiaryPublicId}`) + .set('cookie', result.processor.cookie) + .expect(200); + expect( + body>(detail).data.beneficiary.mobile, + ).toBe('13812345678'); + expect(await prisma.auditLog.count({ where: { action: 'BENEFICIARY_DETAIL_VIEWED' } })).toBe(1); + }); + + it('limits cases to assigned staff while project managers can access the full range', async () => { + const result = await createAcceptedCase(); + const outsider = await createStaff('scope.outsider', 'STAFF'); + await request(app.getHttpServer() as Server) + .get(`/api/v1/admin/service-cases/${result.serviceCase.publicId}`) + .set('cookie', outsider.cookie) + .expect(404); + const manager = await createStaff('scope.manager', 'PROJECT_MANAGER'); + await request(app.getHttpServer() as Server) + .get(`/api/v1/admin/service-cases/${result.serviceCase.publicId}`) + .set('cookie', manager.cookie) + .expect(200); + }); + + it('allows an assigned volunteer to see minimal data and create an idempotent record after start', async () => { + const result = await createAcceptedCase(); + const volunteer = await wechatUser('assigned-volunteer'); + const volunteerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } }); + await prisma.userRole.create({ data: { userId: volunteer.user.id, roleId: volunteerRole.id } }); + await prisma.caseAssignee.create({ + data: { + caseId: ( + await prisma.serviceCase.findUniqueOrThrow({ + where: { publicId: result.serviceCase.publicId }, + }) + ).id, + userId: volunteer.user.id, + assigneeType: 'VOLUNTEER', + role: 'COLLABORATOR', + assignedById: result.processor.user.id, + }, + }); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${result.serviceCase.publicId}/start`) + .set('cookie', result.owner.cookie) + .expect(201); + const detail = await request(app.getHttpServer() as Server) + .get(`/api/v1/service-cases/${result.serviceCase.publicId}`) + .set('authorization', `Bearer ${volunteer.token}`) + .expect(200); + expect( + body>(detail).data.serviceCase + .beneficiaryMobile, + ).toBeNull(); + const requestId = crypto.randomUUID(); + const recordPayload = { + clientRequestId: requestId, + serviceStartAt: '2026-08-12T02:00:00.000Z', + serviceType: 'HOME_VISIT', + content: '上门探访并了解日常生活情况。', + result: '已完成本次探访,服务对象情况稳定。', + beneficiaryConfirmStatus: 'CONFIRMED', + attachmentPublicIds: [], + }; + const first = await request(app.getHttpServer() as Server) + .post(`/api/v1/service-cases/${result.serviceCase.publicId}/records`) + .set('authorization', `Bearer ${volunteer.token}`) + .send(recordPayload) + .expect(201); + const second = await request(app.getHttpServer() as Server) + .post(`/api/v1/service-cases/${result.serviceCase.publicId}/records`) + .set('authorization', `Bearer ${volunteer.token}`) + .send(recordPayload) + .expect(201); + expect(body>(first).data.record.publicId).toBe( + body>(second).data.record.publicId, + ); + expect(await prisma.serviceRecord.count()).toBe(1); + }); + + it('validates image upload metadata and returns only short-lived private URLs', async () => { + const result = await createAcceptedCase(); + const intent = await request(app.getHttpServer() as Server) + .post(`/api/v1/service-cases/${result.serviceCase.publicId}/files/upload-intents`) + .set('cookie', result.owner.cookie) + .send({ + clientRequestId: crypto.randomUUID(), + filename: '探访照片.jpg', + mimeType: 'image/jpeg', + size: 1024, + sha256: 'a'.repeat(64), + }) + .expect(201); + const filePublicId = + body>(intent).data.file.publicId; + await request(app.getHttpServer() as Server) + .post(`/api/v1/files/${filePublicId}/confirm`) + .set('cookie', result.owner.cookie) + .expect(201); + const download = await request(app.getHttpServer() as Server) + .get(`/api/v1/files/${filePublicId}/download-url`) + .set('cookie', result.owner.cookie) + .expect(200); + expect(JSON.stringify(download.body)).toContain('http://storage.test/download/'); + await request(app.getHttpServer() as Server) + .post(`/api/v1/service-cases/${result.serviceCase.publicId}/files/upload-intents`) + .set('cookie', result.owner.cookie) + .send({ + clientRequestId: crypto.randomUUID(), + filename: 'dangerous.html', + mimeType: 'text/html', + size: 100, + }) + .expect(400); + }); +}); diff --git a/apps/api/test/content-notifications.integration-spec.ts b/apps/api/test/content-notifications.integration-spec.ts new file mode 100644 index 0000000..75e0a2a --- /dev/null +++ b/apps/api/test/content-notifications.integration-spec.ts @@ -0,0 +1,251 @@ +import { ValidationPipe, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { + ApiErrorResponse, + ApiSuccessResponse, + ContentPostDetailResponse, + PaginatedContentPostsResponse, + PaginatedNotificationsResponse, + WechatLoginResponse, + StatisticsOverview, +} from '@lehuo/contracts'; +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; +import { PrismaService } from '../src/database/prisma.service'; +const ORIGIN = 'http://localhost:5173'; +class Wx implements WechatAuthAdapter { + exchangeCode(code: string) { + return Promise.resolve({ openid: `phase6-${code}`, unionid: `phase6-u-${code}` }); + } +} +const body = (r: { body: unknown }) => r.body as T; +function cookie(r: { headers: Record }) { + const v = r.headers['set-cookie']; + const values: string[] = Array.isArray(v) + ? v.filter((item): item is string => typeof item === 'string') + : []; + const c = values.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (typeof c !== 'string') throw new Error('no cookie'); + return c.split(';')[0]!; +} +describe('Phase 6 content and notifications', () => { + let app: INestApplication, prisma: PrismaService, passwords: PasswordService; + beforeAll(async () => { + const m = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(WECHAT_AUTH_ADAPTER) + .useValue(new Wx()) + .compile(); + app = m.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + }); + beforeEach(async () => { + await prisma.auditLog.deleteMany(); + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + }); + afterAll(() => app.close()); + async function admin() { + const username = `phase6.${crypto.randomUUID()}`; + const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } }); + const u = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName: '内容管理员' } }, + adminCredential: { + create: { + username, + passwordHash: await passwords.hash('correct horse battery staple'), + }, + }, + }, + }); + await prisma.userRole.create({ data: { userId: u.id, roleId: role.id } }); + const r = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ORIGIN) + .send({ username, password: 'correct horse battery staple' }) + .expect(201); + return cookie(r); + } + async function token(code: string) { + const r = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code }) + .expect(201); + return body>(r).data.accessToken; + } + it('keeps drafts private and enforces reviewed publishing with immutable history', async () => { + const c = await admin(); + const created = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/content') + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + type: 'PUBLIC_WELFARE_NEWS', + title: '社区公益服务动态', + summary: '志愿者与工作人员持续开展社区便民公益服务。', + content: '本周开展长者陪伴、便民咨询和社区活动支持,相关服务记录均已按制度留存。', + }) + .expect(201); + const post = body>(created).data.post; + await request(app.getHttpServer() as Server) + .get(`/api/v1/content/${post.publicId}`) + .expect(404); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/content/${post.publicId}/transition`) + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + targetStatus: 'PENDING_REVIEW', + note: '内容已完成初审,申请发布。', + }) + .expect(201); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/content/${post.publicId}/transition`) + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + targetStatus: 'PUBLISHED', + note: '事实与隐私边界核验通过。', + }) + .expect(201); + const publicList = await request(app.getHttpServer() as Server) + .get('/api/v1/content?page=1&pageSize=20') + .expect(200); + expect( + body>(publicList).data.items, + ).toHaveLength(1); + expect(await prisma.contentPostStatusHistory.count()).toBe(3); + }); + it('requires explicit privacy review for service stories and rejects unsafe markup', async () => { + const c = await admin(); + const unsafe = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/content') + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + type: 'NOTICE', + title: '安全公告', + summary: '这是一条用于验证内容输入安全边界的公告摘要。', + content: ' 这段内容不得被保存到公开内容中。', + }) + .expect(400); + expect(body(unsafe).error.code).toBe('VALIDATION_ERROR'); + const create = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/content') + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + type: 'SERVICE_STORY', + title: '暖心陪伴服务故事', + summary: '经脱敏整理的社区陪伴服务故事,不包含服务对象身份信息。', + content: + '志愿者在工作人员指导下提供陪伴服务。本文不包含姓名、电话、详细地址或可识别的个案描述。', + }) + .expect(201); + const p = body>(create).data.post; + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/content/${p.publicId}/transition`) + .set('origin', ORIGIN) + .set('cookie', c) + .send({ + clientRequestId: crypto.randomUUID(), + targetStatus: 'PENDING_REVIEW', + note: '申请审核。', + }) + .expect(201); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/content/${p.publicId}/transition`) + .set('origin', ORIGIN) + .set('cookie', c) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED', note: '准备发布。' }) + .expect(400); + }); + it('isolates in-app messages by owner and records explicit subscription preference', async () => { + const a = await token('a'), + b = await token('b'); + const ua = await prisma.wechatIdentity.findFirstOrThrow({ + where: { openidHash: { not: '' } }, + orderBy: { createdAt: 'asc' }, + }); + await prisma.notification.create({ + data: { + publicId: crypto.randomUUID(), + userId: ua.userId, + eventKey: 'test-owner', + type: 'APPLICATION_ACCEPTED', + title: '申请已受理', + body: '工作人员将与您联系。', + }, + }); + const mine = await request(app.getHttpServer() as Server) + .get('/api/v1/me/notifications?page=1&pageSize=20') + .set('authorization', `Bearer ${a}`) + .expect(200); + expect(body>(mine).data.items).toHaveLength( + 1, + ); + const other = await request(app.getHttpServer() as Server) + .get('/api/v1/me/notifications?page=1&pageSize=20') + .set('authorization', `Bearer ${b}`) + .expect(200); + expect(body>(other).data.items).toHaveLength( + 0, + ); + await request(app.getHttpServer() as Server) + .put('/api/v1/me/notifications/preferences') + .set('authorization', `Bearer ${a}`) + .send({ items: [{ type: 'APPLICATION_ACCEPTED', inAppEnabled: true, wechatEnabled: true }] }) + .expect(200); + expect((await prisma.notificationPreference.findFirstOrThrow()).authorizedAt).not.toBeNull(); + }); + it('protects statistics and exports a non-sensitive audited summary', async () => { + const adminCookie = await admin(); + const denied = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/statistics/overview') + .expect(401); + expect(body(denied).error.code).toBe('AUTH_REQUIRED'); + const overview = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/statistics/overview?startDate=2026-01-01&endDate=2026-12-31') + .set('origin', ORIGIN) + .set('cookie', adminCookie) + .expect(200); + expect( + body>(overview).data.applicationCount, + ).toBeGreaterThanOrEqual(0); + const exported = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/statistics/export.csv?startDate=2026-01-01&endDate=2026-12-31') + .set('origin', ORIGIN) + .set('cookie', adminCookie) + .expect(200); + expect(exported.text).toContain('申请数量'); + expect(exported.text).not.toMatch(/1[3-9]\d{9}/); + expect(await prisma.auditLog.count({ where: { action: 'STATISTICS_EXPORTED' } })).toBe(1); + }); +}); diff --git a/apps/api/test/follow-ups.integration-spec.ts b/apps/api/test/follow-ups.integration-spec.ts new file mode 100644 index 0000000..5400ab1 --- /dev/null +++ b/apps/api/test/follow-ups.integration-spec.ts @@ -0,0 +1,378 @@ +import { ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import { getStorageToken, type ThrottlerStorageService } from '@nestjs/throttler'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import type { + ApiErrorResponse, + ApiSuccessResponse, + CompleteServiceCaseResponse, + FollowUpMutationResponse, + FollowUpWorkbenchSummary, + RoleCode, +} from '@lehuo/contracts'; + +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { PrismaService } from '../src/database/prisma.service'; +import { SensitiveDataService } from '../src/security/sensitive-data.service'; + +const ADMIN_ORIGIN = 'http://localhost:5173'; + +function body(response: { body: unknown }): T { + return response.body as T; +} + +function adminCookie(response: { headers: Record }): string { + const value = response.headers['set-cookie']; + const cookies = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) throw new Error('Admin session cookie was not set'); + return cookie.split(';')[0] ?? ''; +} + +describe('Phase 4 follow-up closure', () => { + let app: INestApplication; + let prisma: PrismaService; + let passwords: PasswordService; + let sensitiveData: SensitiveDataService; + let throttlerStorage: ThrottlerStorageService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + sensitiveData = app.get(SensitiveDataService); + throttlerStorage = app.get(getStorageToken()); + }); + + beforeEach(async () => { + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + throttlerStorage.storage.clear(); + await prisma.auditLog.deleteMany(); + await prisma.activityRegistrationHistory.deleteMany(); + await prisma.activityRegistration.deleteMany(); + await prisma.activityStatusHistory.deleteMany(); + await prisma.activity.deleteMany(); + await prisma.serviceCase.updateMany({ data: { projectId: null } }); + await prisma.projectStatusHistory.deleteMany(); + await prisma.project.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); + await prisma.followUpStatusHistory.deleteMany(); + await prisma.followUpTask.deleteMany(); + await prisma.serviceRecordFile.deleteMany(); + await prisma.serviceRecordRevision.deleteMany(); + await prisma.serviceRecord.deleteMany(); + await prisma.file.deleteMany(); + await prisma.serviceCaseStatusHistory.deleteMany(); + await prisma.caseAssignee.deleteMany(); + await prisma.serviceCase.deleteMany(); + await prisma.serviceApplication.deleteMany(); + await prisma.beneficiaryTag.deleteMany(); + await prisma.beneficiaryContact.deleteMany(); + await prisma.volunteerStatusHistory.deleteMany(); + await prisma.volunteerSkill.deleteMany(); + await prisma.volunteerInterest.deleteMany(); + await prisma.volunteer.deleteMany(); + await prisma.consent.deleteMany(); + await prisma.beneficiaryProfile.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + await prisma.user.deleteMany(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function createStaff(username: string, roleCode: RoleCode, displayName: string) { + const password = 'correct horse battery staple'; + const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); + const user = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName } }, + adminCredential: { create: { username, passwordHash: await passwords.hash(password) } }, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); + const login = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ADMIN_ORIGIN) + .send({ username, password }) + .expect(201); + return { user, cookie: adminCookie(login) }; + } + + async function createInServiceCase(withRecord: boolean, keyCareFlag = true) { + const owner = await createStaff(`owner.${crypto.randomUUID()}`, 'STAFF', '回访负责人'); + const applicant = await prisma.user.create({ + data: { publicId: crypto.randomUUID(), userType: 'PUBLIC', nickname: '申请人' }, + }); + const consent = await prisma.consent.create({ + data: { + publicId: crypto.randomUUID(), + userId: applicant.id, + consentType: 'SERVICE_APPLICATION', + version: 'service-application-v1', + grantedBy: 'APPLICANT', + grantedAt: new Date(), + }, + }); + const beneficiary = await prisma.beneficiaryProfile.create({ + data: { + publicId: crypto.randomUUID(), + displayNameEncrypted: sensitiveData.encrypt('王奶奶'), + displayNameHash: sensitiveData.lookupHash('beneficiary-display-name', '王奶奶'), + mobileEncrypted: sensitiveData.encrypt('13812345678'), + mobileHash: sensitiveData.lookupHash('mobile', '13812345678'), + regionCode: '丹阳市开发区', + primaryCategory: 'OLD_AGE', + keyCareFlag, + isMinor: false, + createdById: owner.user.id, + updatedById: owner.user.id, + }, + }); + const application = await prisma.serviceApplication.create({ + data: { + publicId: crypto.randomUUID(), + applicationNo: `SQ${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`, + applicantUserId: applicant.id, + beneficiaryId: beneficiary.id, + consentId: consent.id, + clientRequestId: crypto.randomUUID(), + requestFingerprint: 'a'.repeat(64), + applyMode: 'FAMILY', + serviceCategory: 'OLD_AGE', + beneficiaryNameEncrypted: sensitiveData.encrypt('王奶奶'), + contactMobileEncrypted: sensitiveData.encrypt('13812345678'), + contactMobileHash: sensitiveData.lookupHash('mobile', '13812345678'), + regionCode: '丹阳市开发区', + descriptionEncrypted: sensitiveData.encrypt('需要定期探访和生活协助。'), + status: 'IN_SERVICE', + submittedAt: new Date(), + acceptedAt: new Date(), + }, + }); + const serviceCase = await prisma.serviceCase.create({ + data: { + publicId: crypto.randomUUID(), + caseNo: `WO${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`, + applicationId: application.id, + acceptRequestId: crypto.randomUUID(), + acceptRequestFingerprint: 'b'.repeat(64), + beneficiaryId: beneficiary.id, + ownerStaffId: owner.user.id, + category: 'OLD_AGE', + status: 'IN_SERVICE', + summaryEncrypted: sensitiveData.encrypt('定期探访服务计划'), + createdById: owner.user.id, + updatedById: owner.user.id, + assignees: { + create: { + userId: owner.user.id, + assigneeType: 'STAFF', + role: 'OWNER', + assignedById: owner.user.id, + }, + }, + }, + }); + if (withRecord) { + await prisma.serviceRecord.create({ + data: { + publicId: crypto.randomUUID(), + caseId: serviceCase.id, + beneficiaryId: beneficiary.id, + clientRequestId: crypto.randomUUID(), + requestFingerprint: 'c'.repeat(64), + serviceStartAt: new Date(), + serviceType: 'HOME_VISIT', + contentEncrypted: sensitiveData.encrypt('完成上门探访'), + resultEncrypted: sensitiveData.encrypt('服务对象情况稳定'), + createdById: owner.user.id, + updatedById: owner.user.id, + }, + }); + } + return { owner, applicant, beneficiary, application, serviceCase }; + } + + function completionPayload(requestId = crypto.randomUUID()) { + return { + clientRequestId: requestId, + resultSummary: '本阶段探访服务已经按计划完成。', + followUpPlan: '七日后电话回访,了解服务满意度和后续需求。', + followUpDueAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }; + } + + it('requires a service record, then transactionally completes the case and creates a follow-up', async () => { + const baseline = await createInServiceCase(false); + const rejected = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(completionPayload()) + .expect(400); + expect(body(rejected).error.code).toBe('VALIDATION_ERROR'); + expect( + (await prisma.serviceCase.findUniqueOrThrow({ where: { id: baseline.serviceCase.id } })) + .status, + ).toBe('IN_SERVICE'); + await prisma.serviceRecord.create({ + data: { + publicId: crypto.randomUUID(), + caseId: baseline.serviceCase.id, + beneficiaryId: baseline.beneficiary.id, + clientRequestId: crypto.randomUUID(), + requestFingerprint: 'd'.repeat(64), + serviceStartAt: new Date(), + serviceType: 'PHONE_CONTACT', + contentEncrypted: sensitiveData.encrypt('完成电话关怀'), + resultEncrypted: sensitiveData.encrypt('服务目标达成'), + createdById: baseline.owner.user.id, + updatedById: baseline.owner.user.id, + }, + }); + const completed = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(completionPayload()) + .expect(201); + const result = body>(completed).data; + expect(result.serviceCaseStatus).toBe('SERVICE_COMPLETED'); + expect(result.applicationStatus).toBe('FOLLOW_UP_PENDING'); + expect(result.followUp.status).toBe('PENDING'); + expect(await prisma.followUpTask.count()).toBe(1); + expect(await prisma.followUpStatusHistory.count()).toBe(1); + expect(await prisma.auditLog.count({ where: { action: 'SERVICE_CASE_COMPLETED' } })).toBe(1); + const stored = await prisma.followUpTask.findFirstOrThrow(); + expect(stored.reasonEncrypted).not.toContain('七日后'); + }); + + it('replays identical completion, rejects changed content and scopes the workbench', async () => { + const baseline = await createInServiceCase(true); + const requestId = crypto.randomUUID(); + const payload = completionPayload(requestId); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(payload) + .expect(201); + const replay = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(payload) + .expect(201); + expect( + body>(replay).data.idempotentReplay, + ).toBe(true); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send({ ...payload, followUpPlan: '不同的回访安排会触发幂等冲突。' }) + .expect(409); + await prisma.followUpTask.updateMany({ + data: { dueAt: new Date(Date.now() - 60 * 60 * 1000) }, + }); + const outsider = await createStaff(`outsider.${crypto.randomUUID()}`, 'STAFF', '其他人员'); + const ownSummary = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/follow-ups/summary') + .set('cookie', outsider.cookie) + .expect(200); + expect(body>(ownSummary).data.dueThisWeek).toBe(0); + const manager = await createStaff( + `manager.${crypto.randomUUID()}`, + 'PROJECT_MANAGER', + '项目负责人', + ); + const managerSummary = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/follow-ups/summary') + .set('cookie', manager.cookie) + .expect(200); + expect( + body>(managerSummary).data.keyCarePending, + ).toBe(1); + expect(body>(managerSummary).data.overdue).toBe(1); + }); + + it('creates a linked continuation application with independent consent and closes the original chain', async () => { + const baseline = await createInServiceCase(true); + const completion = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(completionPayload()) + .expect(201); + const followUp = + body>(completion).data.followUp; + const completeRequest = { + clientRequestId: crypto.randomUUID(), + resultCode: 'NEED_MORE_HELP', + resultNote: '服务对象反馈仍需进一步的生活协助。', + continuationConsentConfirmed: true, + continuationDescription: '继续提供为期一个月的生活协助,并重新评估后续服务需求。', + }; + const response = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/follow-ups/${followUp.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(completeRequest) + .expect(201); + const result = body>(response).data; + expect(result.followUp.status).toBe('COMPLETED'); + expect(result.followUp.continuedApplicationPublicId).not.toBeNull(); + expect( + ( + await prisma.serviceApplication.findUniqueOrThrow({ + where: { id: baseline.application.id }, + }) + ).status, + ).toBe('CLOSED'); + const continued = await prisma.serviceApplication.findFirstOrThrow({ + where: { sourceFollowUpTaskId: { not: null } }, + include: { consent: true }, + }); + expect(continued.status).toBe('SUBMITTED'); + expect(continued.parentApplicationId).toBe(baseline.application.id); + expect(continued.consent.version).toBe('service-continuation-v1'); + expect( + await prisma.serviceApplicationStatusHistory.count({ + where: { applicationId: continued.id }, + }), + ).toBe(1); + const replay = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/follow-ups/${followUp.publicId}/complete`) + .set('cookie', baseline.owner.cookie) + .send(completeRequest) + .expect(201); + expect(body>(replay).data.idempotentReplay).toBe( + true, + ); + expect( + await prisma.serviceApplication.count({ where: { sourceFollowUpTaskId: { not: null } } }), + ).toBe(1); + }); +}); diff --git a/apps/api/test/participation.integration-spec.ts b/apps/api/test/participation.integration-spec.ts new file mode 100644 index 0000000..c67d5a4 --- /dev/null +++ b/apps/api/test/participation.integration-spec.ts @@ -0,0 +1,412 @@ +import { ValidationPipe } from '@nestjs/common'; +import type { INestApplication } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { Test } from '@nestjs/testing'; +import { getStorageToken, type ThrottlerStorageService } from '@nestjs/throttler'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import type { + ActivityDetailResponse, + ActivityRegistrationResponse, + ApiErrorResponse, + ApiSuccessResponse, + ProjectDetailResponse, + RoleCode, + VolunteerProfileResponse, + WechatLoginResponse, +} from '@lehuo/contracts'; + +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; +import { PrismaService } from '../src/database/prisma.service'; + +const ADMIN_ORIGIN = 'http://localhost:5173'; + +class PhaseFiveWechatAdapter implements WechatAuthAdapter { + exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { + return Promise.resolve({ openid: `phase5-openid-${code}`, unionid: `phase5-unionid-${code}` }); + } +} + +function body(response: { body: unknown }): T { + return response.body as T; +} + +function adminCookie(response: { headers: Record }): string { + const value = response.headers['set-cookie']; + const cookies = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) throw new Error('Admin session cookie was not set'); + return cookie.split(';')[0] ?? ''; +} + +describe('Phase 5 projects, activities and volunteers', () => { + let app: INestApplication; + let prisma: PrismaService; + let passwords: PasswordService; + let throttlerStorage: ThrottlerStorageService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(WECHAT_AUTH_ADAPTER) + .useValue(new PhaseFiveWechatAdapter()) + .compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + throttlerStorage = app.get(getStorageToken()); + }); + + beforeEach(async () => { + throttlerStorage.storage.clear(); + await prisma.auditLog.deleteMany(); + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + await prisma.activityRegistrationHistory.deleteMany(); + await prisma.activityRegistration.deleteMany(); + await prisma.activityStatusHistory.deleteMany(); + await prisma.activity.deleteMany(); + await prisma.serviceCase.updateMany({ data: { projectId: null } }); + await prisma.projectStatusHistory.deleteMany(); + await prisma.project.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); + await prisma.followUpStatusHistory.deleteMany(); + await prisma.followUpTask.deleteMany(); + await prisma.serviceRecordFile.deleteMany(); + await prisma.serviceRecordRevision.deleteMany(); + await prisma.serviceRecord.deleteMany(); + await prisma.file.deleteMany(); + await prisma.serviceCaseStatusHistory.deleteMany(); + await prisma.caseAssignee.deleteMany(); + await prisma.serviceCase.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany(); + await prisma.beneficiaryTag.deleteMany(); + await prisma.beneficiaryContact.deleteMany(); + await prisma.volunteerStatusHistory.deleteMany(); + await prisma.volunteerSkill.deleteMany(); + await prisma.volunteerInterest.deleteMany(); + await prisma.volunteer.deleteMany(); + await prisma.consent.deleteMany(); + await prisma.beneficiaryProfile.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + await prisma.user.deleteMany(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function createStaff(username: string, roleCode: RoleCode, displayName: string) { + const password = 'correct horse battery staple'; + const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); + const user = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName } }, + adminCredential: { create: { username, passwordHash: await passwords.hash(password) } }, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); + const login = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ADMIN_ORIGIN) + .send({ username, password }) + .expect(201); + return { user, cookie: adminCookie(login) }; + } + + async function wechatToken(code: string): Promise { + const response = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code }) + .expect(201); + return body>(response).data.accessToken; + } + + async function applyAndApprove( + token: string, + adminCookieValue: string, + suffix: string, + ): Promise { + const applied = await request(app.getHttpServer() as Server) + .put('/api/v1/me/volunteer') + .set('authorization', `Bearer ${token}`) + .send({ + clientRequestId: crypto.randomUUID(), + realName: `志愿者${suffix}`, + mobile: `1390000${suffix.padStart(4, '0')}`, + regionCode: '丹阳市云阳街道', + availabilityNote: '周末上午可以参加服务活动', + skillCodes: ['EVENT_SUPPORT'], + interestCategories: ['OLD_AGE'], + privacyConsent: true, + }) + .expect(200); + const profile = body>(applied).data.volunteer!; + const approved = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/volunteers/${profile.publicId}/review`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', adminCookieValue) + .send({ + clientRequestId: crypto.randomUUID(), + targetStatus: 'APPROVED', + note: '资料核验通过', + }) + .expect(201); + return { + volunteer: + body>(approved) + .data.volunteer, + }; + } + + it('completes project, activity, registration, check-in and service-minute state machines', async () => { + const admin = await createStaff('phase5.admin', 'ADMIN', '系统管理员'); + const manager = await createStaff('phase5.manager', 'PROJECT_MANAGER', '公益项目负责人'); + const volunteerToken = await wechatToken('volunteer-one'); + await applyAndApprove(volunteerToken, admin.cookie, '0001'); + + const projectCreated = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/projects') + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ + clientRequestId: crypto.randomUUID(), + name: '暖心探访计划', + category: 'OLD_AGE', + summary: '组织志愿者为社区高龄老人提供定期探访与陪伴服务。', + content: '项目由社区工作人员协调,以公开活动方式招募已认证志愿者参与陪伴和活动支持。', + managerUserPublicId: manager.user.publicId, + startDate: '2026-08-01', + endDate: '2026-12-31', + targetPeople: 100, + isPublic: true, + sortOrder: 10, + }) + .expect(201); + const project = body>(projectCreated).data.project; + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/projects/${project.publicId}/transition`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' }) + .expect(201); + + const activityCreated = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/activities') + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ + clientRequestId: crypto.randomUUID(), + projectPublicId: project.publicId, + title: '重阳暖心陪伴活动', + summary: '招募志愿者参与社区长者陪伴和现场活动支持。', + content: '志愿者将在工作人员组织下完成签到、陪伴交流和现场秩序协助等服务内容。', + startAt: '2026-10-18T01:00:00.000Z', + endAt: '2026-10-18T05:00:00.000Z', + registrationDeadline: '2026-10-17T12:00:00.000Z', + locationText: '丹阳市云阳街道社区服务中心', + capacity: 1, + }) + .expect(201); + const activity = + body>(activityCreated).data.activity; + const reviewRequestId = crypto.randomUUID(); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activities/${activity.publicId}/transition`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' }) + .expect(201); + + const registrationId = crypto.randomUUID(); + const firstRegistration = await request(app.getHttpServer() as Server) + .post(`/api/v1/activities/${activity.publicId}/registrations`) + .set('authorization', `Bearer ${volunteerToken}`) + .send({ clientRequestId: registrationId }) + .expect(201); + const registration = + body>(firstRegistration).data.registration; + const replay = await request(app.getHttpServer() as Server) + .post(`/api/v1/activities/${activity.publicId}/registrations`) + .set('authorization', `Bearer ${volunteerToken}`) + .send({ clientRequestId: registrationId }) + .expect(201); + expect( + body>(replay).data.idempotentReplay, + ).toBe(true); + + const secondToken = await wechatToken('volunteer-two'); + await applyAndApprove(secondToken, admin.cookie, '0002'); + const full = await request(app.getHttpServer() as Server) + .post(`/api/v1/activities/${activity.publicId}/registrations`) + .set('authorization', `Bearer ${secondToken}`) + .send({ clientRequestId: crypto.randomUUID() }) + .expect(409); + expect(body(full).error.code).toBe('ACTIVITY_FULL'); + + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activity-registrations/${registration.publicId}/review`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ + clientRequestId: reviewRequestId, + targetStatus: 'APPROVED', + note: '报名信息符合活动要求', + }) + .expect(201); + const reviewReplay = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activity-registrations/${registration.publicId}/review`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ + clientRequestId: reviewRequestId, + targetStatus: 'APPROVED', + note: '报名信息符合活动要求', + }) + .expect(201); + expect( + body>(reviewReplay).data.idempotentReplay, + ).toBe(true); + const checkInRequestId = crypto.randomUUID(); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activities/${activity.publicId}/transition`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'REGISTRATION_CLOSED' }) + .expect(201); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activities/${activity.publicId}/transition`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'IN_PROGRESS' }) + .expect(201); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activity-registrations/${registration.publicId}/check-in`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: checkInRequestId, targetStatus: 'CHECKED_IN' }) + .expect(201); + const checkInReplay = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activity-registrations/${registration.publicId}/check-in`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: checkInRequestId, targetStatus: 'CHECKED_IN' }) + .expect(201); + expect( + body>(checkInReplay).data.idempotentReplay, + ).toBe(true); + await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activities/${activity.publicId}/transition`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), targetStatus: 'COMPLETED' }) + .expect(201); + const minutes = await request(app.getHttpServer() as Server) + .post(`/api/v1/admin/activity-registrations/${registration.publicId}/service-minutes`) + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ clientRequestId: crypto.randomUUID(), serviceMinutes: 180 }) + .expect(201); + expect( + body>(minutes).data.registration + .serviceMinutes, + ).toBe(180); + + const mine = await request(app.getHttpServer() as Server) + .get('/api/v1/me/volunteer') + .set('authorization', `Bearer ${volunteerToken}`) + .expect(200); + expect( + body>(mine).data.volunteer?.totalServiceMinutes, + ).toBe(180); + expect( + await prisma.activityRegistrationHistory.count({ + where: { + registrationId: ( + await prisma.activityRegistration.findUniqueOrThrow({ + where: { publicId: registration.publicId }, + }) + ).id, + }, + }), + ).toBe(4); + expect( + await prisma.auditLog.count({ where: { resourcePublicId: registration.publicId } }), + ).toBeGreaterThanOrEqual(4); + }); + + it('keeps draft resources private and rejects unapproved volunteers', async () => { + const admin = await createStaff('phase5.privacy.admin', 'ADMIN', '系统管理员'); + const manager = await createStaff('phase5.privacy.manager', 'PROJECT_MANAGER', '项目负责人'); + const created = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/projects') + .set('origin', ADMIN_ORIGIN) + .set('cookie', admin.cookie) + .send({ + clientRequestId: crypto.randomUUID(), + name: '内部筹备项目', + category: 'OTHER', + summary: '该项目尚处于内部筹备阶段,不应出现在公开项目列表中。', + content: '项目内容仍在完善,完成负责人和周期核对后再由有权限的工作人员执行发布。', + managerUserPublicId: manager.user.publicId, + startDate: '2026-09-01', + endDate: '2026-10-31', + isPublic: true, + sortOrder: 0, + }) + .expect(201); + const project = body>(created).data.project; + await request(app.getHttpServer() as Server) + .get(`/api/v1/public/projects/${project.publicId}`) + .expect(404); + + const activity = await prisma.activity.create({ + data: { + publicId: crypto.randomUUID(), + activityNo: `HD${crypto.randomUUID().slice(0, 20)}`, + clientRequestId: crypto.randomUUID(), + requestFingerprint: 'a'.repeat(64), + title: '公开报名测试活动', + summary: '用于验证未认证用户不能报名的活动摘要内容。', + content: '用于验证志愿者状态授权边界的完整活动说明内容。', + startAt: new Date('2026-10-18T01:00:00.000Z'), + endAt: new Date('2026-10-18T05:00:00.000Z'), + registrationDeadline: new Date('2026-10-17T12:00:00.000Z'), + locationText: '社区服务中心', + status: 'PUBLISHED', + createdById: admin.user.id, + updatedById: admin.user.id, + }, + }); + const publicToken = await wechatToken('not-approved'); + const denied = await request(app.getHttpServer() as Server) + .post(`/api/v1/activities/${activity.publicId}/registrations`) + .set('authorization', `Bearer ${publicToken}`) + .send({ clientRequestId: crypto.randomUUID() }) + .expect(403); + expect(body(denied).error.code).toBe('VOLUNTEER_NOT_APPROVED'); + }); +}); diff --git a/apps/api/test/run-integration.ts b/apps/api/test/run-integration.ts new file mode 100644 index 0000000..7c0a856 --- /dev/null +++ b/apps/api/test/run-integration.ts @@ -0,0 +1,41 @@ +import { spawn } from 'node:child_process'; + +function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: process.cwd(), + env: environment, + shell: false, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${String(code)}`)); + }); + }); +} + +async function main(): Promise { + const testDatabaseUrl = process.env.TEST_DATABASE_URL; + if (!testDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for integration tests'); + const parsed = new URL(testDatabaseUrl); + if (parsed.protocol !== 'mysql:' || parsed.pathname !== '/lehuo_test') { + throw new Error('Integration tests are restricted to the lehuo_test MySQL database'); + } + const pnpmCli = process.env.npm_execpath; + if (!pnpmCli) throw new Error('npm_execpath is required to locate the active pnpm CLI'); + const environment = { + ...process.env, + APP_ENV: 'test', + DATABASE_URL: testDatabaseUrl, + }; + await run(process.execPath, [pnpmCli, 'exec', 'prisma', 'migrate', 'deploy'], environment); + await run( + process.execPath, + [pnpmCli, 'exec', 'vitest', 'run', '--config', 'vitest.integration.config.ts'], + environment, + ); +} + +void main(); diff --git a/apps/api/test/service-applications.integration-spec.ts b/apps/api/test/service-applications.integration-spec.ts new file mode 100644 index 0000000..9ecada1 --- /dev/null +++ b/apps/api/test/service-applications.integration-spec.ts @@ -0,0 +1,322 @@ +import type { INestApplication } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { Test } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import type { + ApiErrorResponse, + ApiSuccessResponse, + CreateServiceApplicationResponse, + PaginatedServiceApplicationsResponse, + RoleCode, + ServiceApplicationDetailResponse, + ServiceCategoriesResponse, + WechatLoginResponse, +} from '@lehuo/contracts'; + +import { AppModule } from '../src/app.module'; +import { PasswordService } from '../src/auth/password.service'; +import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; +import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; +import { PrismaService } from '../src/database/prisma.service'; + +const ADMIN_ORIGIN = 'http://localhost:5173'; +let submitIpSequence = 1; + +class PhaseTwoWechatAdapter implements WechatAuthAdapter { + exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { + return Promise.resolve({ openid: `phase2-openid-${code}`, unionid: `phase2-unionid-${code}` }); + } +} + +function body(response: { body: unknown }): T { + return response.body as T; +} + +function adminCookie(response: { headers: Record }): string { + const value = response.headers['set-cookie']; + const cookies = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) throw new Error('Admin session cookie was not set'); + return cookie.split(';')[0] ?? ''; +} + +describe('Phase 2 service applications', () => { + let app: INestApplication; + let prisma: PrismaService; + let passwords: PasswordService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(WECHAT_AUTH_ADAPTER) + .useValue(new PhaseTwoWechatAdapter()) + .compile(); + app = moduleRef.createNestApplication(); + (app as NestExpressApplication).set('trust proxy', 1); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + prisma = app.get(PrismaService); + passwords = app.get(PasswordService); + }); + + beforeEach(async () => { + await prisma.notificationDelivery.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.notificationPreference.deleteMany(); + await prisma.contentPostStatusHistory.deleteMany(); + await prisma.contentPost.deleteMany(); + await prisma.auditLog.deleteMany(); + await prisma.activityRegistrationHistory.deleteMany(); + await prisma.activityRegistration.deleteMany(); + await prisma.activityStatusHistory.deleteMany(); + await prisma.activity.deleteMany(); + await prisma.serviceCase.updateMany({ data: { projectId: null } }); + await prisma.projectStatusHistory.deleteMany(); + await prisma.project.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); + await prisma.followUpStatusHistory.deleteMany(); + await prisma.followUpTask.deleteMany(); + await prisma.serviceRecordFile.deleteMany(); + await prisma.serviceRecordRevision.deleteMany(); + await prisma.serviceRecord.deleteMany(); + await prisma.file.deleteMany(); + await prisma.serviceCaseStatusHistory.deleteMany(); + await prisma.caseAssignee.deleteMany(); + await prisma.serviceCase.deleteMany(); + await prisma.serviceApplicationStatusHistory.deleteMany(); + await prisma.serviceApplication.deleteMany(); + await prisma.beneficiaryTag.deleteMany(); + await prisma.beneficiaryContact.deleteMany(); + await prisma.volunteerStatusHistory.deleteMany(); + await prisma.volunteerSkill.deleteMany(); + await prisma.volunteerInterest.deleteMany(); + await prisma.volunteer.deleteMany(); + await prisma.consent.deleteMany(); + await prisma.beneficiaryProfile.deleteMany(); + await prisma.authSession.deleteMany(); + await prisma.userRole.deleteMany(); + await prisma.adminCredential.deleteMany(); + await prisma.staffProfile.deleteMany(); + await prisma.wechatIdentity.deleteMany(); + await prisma.user.deleteMany(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function wechatToken(code: string): Promise { + const response = await request(app.getHttpServer() as Server) + .post('/api/v1/auth/wechat/login') + .send({ code }) + .expect(201); + return body>(response).data.accessToken; + } + + async function createStaff(username: string, roleCode: RoleCode): Promise { + const password = 'correct horse battery staple'; + const passwordHash = await passwords.hash(password); + const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); + const user = await prisma.user.create({ + data: { + publicId: crypto.randomUUID(), + userType: 'STAFF', + staffProfile: { create: { displayName: '申请测试人员' } }, + adminCredential: { create: { username, passwordHash } }, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); + const login = await request(app.getHttpServer() as Server) + .post('/api/v1/admin/auth/login') + .set('origin', ADMIN_ORIGIN) + .send({ username, password }) + .expect(201); + return adminCookie(login); + } + + function applicationPayload(clientRequestId = crypto.randomUUID()) { + return { + clientRequestId, + serviceCategory: 'OLD_AGE', + applyMode: 'SELF', + beneficiaryName: '王阿姨', + contactMobile: '13812345678', + regionCode: '丹阳市开发区', + description: '近期行动不便,希望能够得到定期探访和生活协助。', + preferredContactTime: '工作日上午', + needsHomeVisit: true, + mobilityDifficulty: true, + privacyConsent: true, + }; + } + + async function submit(token: string, payload: Record = applicationPayload()) { + return request(app.getHttpServer() as Server) + .post('/api/v1/service-applications') + .set('authorization', `Bearer ${token}`) + .set('x-forwarded-for', `198.51.100.${submitIpSequence++}`) + .send(payload); + } + + it('publishes the six approved service categories without authentication', async () => { + const response = await request(app.getHttpServer() as Server) + .get('/api/v1/service-categories') + .expect(200); + const result = body>(response).data; + expect(result.items).toHaveLength(6); + expect(result.items.map((item) => item.code)).toContain('CHILD'); + }); + + it('submits a self application transactionally with encrypted L3 data, consent, history, and audit', async () => { + const token = await wechatToken('self-user'); + const response = await submit(token).then((result) => { + expect(result.status).toBe(201); + return result; + }); + const result = body>(response).data; + expect(result.idempotentReplay).toBe(false); + expect(result.application.applicationNo).toMatch(/^LH\d{8}[0-9A-F]{10}$/); + expect(result.application.status).toBe('SUBMITTED'); + expect(result.application.history).toHaveLength(1); + + const stored = await prisma.serviceApplication.findFirstOrThrow(); + expect(stored.beneficiaryNameEncrypted).not.toContain('王阿姨'); + expect(stored.contactMobileEncrypted).not.toContain('13812345678'); + expect(stored.descriptionEncrypted).not.toContain('定期探访'); + expect(await prisma.consent.count()).toBe(1); + expect(await prisma.serviceApplicationStatusHistory.count()).toBe(1); + expect( + await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_SUBMITTED' } }), + ).toBe(1); + }); + + it('replays the same idempotency key once and rejects changed content', async () => { + const token = await wechatToken('duplicate-user'); + const requestId = crypto.randomUUID(); + const payload = applicationPayload(requestId); + const first = await submit(token, payload).then((result) => { + expect(result.status).toBe(201); + return result; + }); + const second = await submit(token, payload).then((result) => { + expect(result.status).toBe(201); + return result; + }); + expect( + body>(second).data.idempotentReplay, + ).toBe(true); + expect( + body>(second).data.application.publicId, + ).toBe( + body>(first).data.application.publicId, + ); + expect(await prisma.serviceApplication.count()).toBe(1); + const conflict = await submit(token, { + ...payload, + description: `${payload.description} 内容已修改`, + }); + expect(conflict.status).toBe(409); + expect(body(conflict).error.code).toBe('DUPLICATE_REQUEST'); + }); + + it('supports a family application and preserves the approved relationship mode', async () => { + const token = await wechatToken('family-user'); + const response = await submit(token, { + ...applicationPayload(), + applyMode: 'FAMILY', + beneficiaryName: '李奶奶', + }).then((result) => { + expect(result.status).toBe(201); + return result; + }); + const application = + body>(response).data.application; + expect(application.applyMode).toBe('FAMILY'); + expect(application.applyModeLabel).toBe('帮家人申请'); + }); + + it('lists only the current user applications and denies guessed public identifiers', async () => { + const ownerToken = await wechatToken('owner'); + const otherToken = await wechatToken('other'); + const submitted = await submit(ownerToken).then((response) => { + expect(response.status).toBe(201); + return body>(response).data.application; + }); + const mine = await request(app.getHttpServer() as Server) + .get('/api/v1/service-applications') + .set('authorization', `Bearer ${ownerToken}`) + .expect(200); + expect(body>(mine).data.total).toBe(1); + const others = await request(app.getHttpServer() as Server) + .get('/api/v1/service-applications') + .set('authorization', `Bearer ${otherToken}`) + .expect(200); + expect(body>(others).data.total).toBe( + 0, + ); + await request(app.getHttpServer() as Server) + .get(`/api/v1/service-applications/${submitted.publicId}`) + .set('authorization', `Bearer ${otherToken}`) + .expect(403); + }); + + it('rejects missing consent, unknown fields, and invalid contact values', async () => { + const token = await wechatToken('validation-user'); + const response = await submit(token, { + ...applicationPayload(), + contactMobile: '12345', + privacyConsent: false, + internalStatus: 'ACCEPTED', + }); + expect(response.status).toBe(400); + expect(body(response).error.code).toBe('VALIDATION_ERROR'); + expect(await prisma.serviceApplication.count()).toBe(0); + }); + + it('allows staff to filter masked lists and audits full detail access', async () => { + const token = await wechatToken('admin-visible-owner'); + const submitted = await submit(token).then((response) => { + expect(response.status).toBe(201); + return body>(response).data.application; + }); + const cookie = await createStaff('phase2.staff', 'STAFF'); + const list = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/service-applications?serviceCategory=OLD_AGE&status=SUBMITTED') + .set('cookie', cookie) + .expect(200); + const listData = body>(list).data; + expect(listData.total).toBe(1); + expect(listData.items[0]?.contactMobileMasked).toBe('138****5678'); + expect(JSON.stringify(listData)).not.toContain('13812345678'); + const detail = await request(app.getHttpServer() as Server) + .get(`/api/v1/admin/service-applications/${submitted.publicId}`) + .set('cookie', cookie) + .expect(200); + expect( + body>(detail).data.application + .contactMobile, + ).toBe('13812345678'); + expect( + await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_DETAIL_VIEWED' } }), + ).toBe(1); + }); + + it('denies the admin application list to a role without application read permission', async () => { + const cookie = await createStaff('phase2.volunteer', 'VOLUNTEER'); + const response = await request(app.getHttpServer() as Server) + .get('/api/v1/admin/service-applications') + .set('cookie', cookie) + .expect(403); + expect(body(response).error.code).toBe('FORBIDDEN'); + }); +}); diff --git a/apps/api/test/setup-env.ts b/apps/api/test/setup-env.ts new file mode 100644 index 0000000..63ced6c --- /dev/null +++ b/apps/api/test/setup-env.ts @@ -0,0 +1,18 @@ +process.env.APP_ENV = 'test'; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +process.env.API_PORT = '3100'; +process.env.SESSION_TOKEN_PEPPER = 'generated-test-session-token-pepper-2026'; +process.env.AUDIT_IP_HASH_KEY = 'generated-test-audit-ip-hash-key-2026'; +process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 11).toString('base64'); +process.env.ADMIN_WEB_ORIGIN = 'http://localhost:5173'; +process.env.ADMIN_COOKIE_SECURE = 'false'; +process.env.AUTH_SESSION_TTL_SECONDS = '3600'; +process.env.WECHAT_SESSION_TTL_SECONDS = '3600'; +process.env.WECHAT_APP_ID = 'test-wechat-app-id'; +process.env.WECHAT_APP_SECRET = 'generated-test-wechat-secret'; +process.env.OBJECT_STORAGE_ENDPOINT = 'http://127.0.0.1:9000'; +process.env.OBJECT_STORAGE_REGION = 'auto'; +process.env.OBJECT_STORAGE_BUCKET = 'lehuo-test-private'; +process.env.OBJECT_STORAGE_ACCESS_KEY = 'generated-test-storage-access'; +process.env.OBJECT_STORAGE_SECRET_KEY = 'generated-test-storage-secret'; +process.env.OBJECT_STORAGE_FORCE_PATH_STYLE = 'true'; diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..93a3919 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..91503a9 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": ".", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts", "vitest*.config.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..aacc4e1 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + }, +}); diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts new file mode 100644 index 0000000..45779c3 --- /dev/null +++ b/apps/api/vitest.integration.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.integration-spec.ts'], + fileParallelism: false, + setupFiles: ['test/setup-env.ts'], + testTimeout: 20_000, + hookTimeout: 60_000, + }, +}); diff --git a/apps/miniprogram/README.md b/apps/miniprogram/README.md new file mode 100644 index 0000000..50901b1 --- /dev/null +++ b/apps/miniprogram/README.md @@ -0,0 +1,5 @@ +# 微信小程序 + +微信原生小程序 + TypeScript。Phase 1 已接入微信登录和短期会话;Phase 2–4 提供服务申请、协作任务和闭环;Phase 5 提供公开公益项目/活动、在线报名、志愿者认证、个人报名进度和服务时长。敏感申请草稿、志愿者资料和工单内容不写入本地持久化。 + +使用微信开发者工具导入本目录;真实 AppID 只保存在本地私有配置中,不提交仓库。本地默认 API 为 `http://localhost:3000`;提审或发布前必须把 `miniprogram/app.ts` 中的地址替换为已加入微信 request 合法域名的 HTTPS 地址,不得发布本地 HTTP 配置。 diff --git a/apps/miniprogram/miniprogram/app.json b/apps/miniprogram/miniprogram/app.json new file mode 100644 index 0000000..c17d055 --- /dev/null +++ b/apps/miniprogram/miniprogram/app.json @@ -0,0 +1,27 @@ +{ + "pages": [ + "pages/index/index", + "pages/application-form/application-form", + "pages/application-success/application-success", + "pages/my-applications/my-applications", + "pages/application-detail/application-detail", + "pages/assigned-cases/assigned-cases", + "pages/assigned-case-detail/assigned-case-detail", + "pages/projects/projects", + "pages/project-detail/project-detail", + "pages/activities/activities", + "pages/activity-detail/activity-detail", + "pages/volunteer-center/volunteer-center", + "pages/content/content", + "pages/content-detail/content-detail", + "pages/notifications/notifications" + ], + "window": { + "backgroundColor": "#F5F5F5", + "backgroundTextStyle": "light", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "navigationBarTitleText": "乐活公益" + }, + "sitemapLocation": "sitemap.json" +} diff --git a/apps/miniprogram/miniprogram/app.ts b/apps/miniprogram/miniprogram/app.ts new file mode 100644 index 0000000..405fcc4 --- /dev/null +++ b/apps/miniprogram/miniprogram/app.ts @@ -0,0 +1,6 @@ +App({ + globalData: { + // 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。 + apiBaseUrl: 'http://localhost:3000', + }, +}); diff --git a/apps/miniprogram/miniprogram/app.wxss b/apps/miniprogram/miniprogram/app.wxss new file mode 100644 index 0000000..e1a67c0 --- /dev/null +++ b/apps/miniprogram/miniprogram/app.wxss @@ -0,0 +1,164 @@ +page { + --color-primary: #ff6b35; + --color-primary-pressed: #e95a28; + --color-primary-soft: #fff1eb; + --color-success: #2f8f2f; + --color-info: #1677c8; + --color-danger: #b42318; + --color-text: #1f2329; + --color-text-secondary: #646a73; + --color-border: #e5e6eb; + --color-background: #f5f5f5; + --color-surface: #ffffff; + min-height: 100%; + color: var(--color-text); + background: var(--color-background); + font-family: + -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', + sans-serif; + font-size: 32rpx; +} + +button { + font: inherit; +} + +button::after { + border: 0; +} + +.page { + min-height: 100vh; + padding: 32rpx 32rpx calc(48rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.eyebrow { + display: block; + color: var(--color-primary-pressed); + font-size: 28rpx; + font-weight: 600; +} + +.primary-button, +.secondary-button { + display: flex; + min-height: 96rpx; + margin: 24rpx 0 0; + padding: 20rpx 32rpx; + align-items: center; + justify-content: center; + border-radius: 24rpx; + box-sizing: border-box; + font-size: 34rpx; + font-weight: 600; + line-height: 1.4; +} + +.primary-button { + color: #ffffff; + background: var(--color-primary); +} + +.primary-button:active { + background: var(--color-primary-pressed); +} + +.secondary-button { + color: var(--color-primary-pressed); + background: var(--color-primary-soft); + border: 2rpx solid var(--color-primary); +} + +.primary-button[disabled], +.secondary-button[disabled] { + opacity: 0.5; +} + +.state-card, +.form-card, +.detail-card, +.detail-summary, +.success-card { + padding: 32rpx; + border: 2rpx solid var(--color-border); + border-radius: 32rpx; + background: var(--color-surface); + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05); +} + +.state-card { + display: flex; + min-height: 280rpx; + flex-direction: column; + align-items: stretch; + justify-content: center; + text-align: center; +} + +.state-title { + font-size: 38rpx; + font-weight: 600; +} + +.state-text { + display: block; + margin-top: 16rpx; + color: var(--color-text-secondary); + line-height: 1.7; +} + +.skeleton-card, +.skeleton-line { + overflow: hidden; + border-radius: 20rpx; + background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%); + background-size: 200% 100%; + animation: skeleton 1.4s ease-in-out infinite; +} + +.skeleton-card { + height: 160rpx; + margin-bottom: 24rpx; +} + +.skeleton-line { + width: 60%; + height: 28rpx; + margin: 12rpx auto; +} + +.skeleton-line--wide { + width: 84%; + height: 44rpx; +} + +.status-pill { + display: inline-flex; + min-height: 48rpx; + padding: 8rpx 16rpx; + align-items: center; + border-radius: 12rpx; + color: #145ea8; + background: #eaf4ff; + font-size: 26rpx; + font-weight: 600; + line-height: 1.2; +} + +.inline-error { + display: block; + margin: 20rpx 8rpx 0; + color: var(--color-danger); + font-size: 28rpx; + line-height: 1.6; +} + +@keyframes skeleton { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/apps/miniprogram/miniprogram/constants/app-info.spec.ts b/apps/miniprogram/miniprogram/constants/app-info.spec.ts new file mode 100644 index 0000000..e534675 --- /dev/null +++ b/apps/miniprogram/miniprogram/constants/app-info.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import { MINI_PROGRAM_TITLE } from './app-info'; + +describe('mini program information', () => { + it('uses the approved brand name', () => { + expect(MINI_PROGRAM_TITLE).toBe('乐活公益'); + }); +}); diff --git a/apps/miniprogram/miniprogram/constants/app-info.ts b/apps/miniprogram/miniprogram/constants/app-info.ts new file mode 100644 index 0000000..078490a --- /dev/null +++ b/apps/miniprogram/miniprogram/constants/app-info.ts @@ -0,0 +1,2 @@ +export const MINI_PROGRAM_TITLE = '乐活公益'; +export const AUTH_READY_MESSAGE = '身份验证已完成,服务功能将在后续阶段开放。'; diff --git a/apps/miniprogram/miniprogram/pages/activities/activities.json b/apps/miniprogram/miniprogram/pages/activities/activities.json new file mode 100644 index 0000000..0481590 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activities/activities.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "公益活动" } diff --git a/apps/miniprogram/miniprogram/pages/activities/activities.ts b/apps/miniprogram/miniprogram/pages/activities/activities.ts new file mode 100644 index 0000000..8828c93 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activities/activities.ts @@ -0,0 +1,24 @@ +import type { ActivityListItem } from '@lehuo/contracts'; +import { fetchPublicActivities } from '../../services/participation'; +Page({ + data: { loading: true, message: '', projectPublicId: '', items: [] as ActivityListItem[] }, + onLoad(options: Record) { + const projectPublicId = options['projectPublicId'] ?? ''; + this.setData({ projectPublicId }); + void this.load(); + }, + async load() { + try { + const result = await fetchPublicActivities(this.data.projectPublicId || undefined); + this.setData({ items: result.items }); + } catch { + this.setData({ message: '活动加载失败,请稍后重试' }); + } finally { + this.setData({ loading: false }); + } + }, + open(event: WechatMiniprogram.BaseEvent) { + const publicId = event.currentTarget.dataset['id'] as string; + void wx.navigateTo({ url: `/pages/activity-detail/activity-detail?publicId=${publicId}` }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/activities/activities.wxml b/apps/miniprogram/miniprogram/pages/activities/activities.wxml new file mode 100644 index 0000000..6ae64b0 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activities/activities.wxml @@ -0,0 +1 @@ +公益活动已认证志愿者可在线报名正在加载活动…{{message}}暂无可查看活动 diff --git a/apps/miniprogram/miniprogram/pages/activities/activities.wxss b/apps/miniprogram/miniprogram/pages/activities/activities.wxss new file mode 100644 index 0000000..27dcb37 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activities/activities.wxss @@ -0,0 +1,7 @@ +@import '../../app.wxss'; +.card-meta { + display: block; + margin-top: 12rpx; + color: #646a73; + font-size: 24rpx; +} diff --git a/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.json b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.json new file mode 100644 index 0000000..83b1652 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "活动详情" } diff --git a/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.ts b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.ts new file mode 100644 index 0000000..61ba08f --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.ts @@ -0,0 +1,38 @@ +import type { ActivityDetail } from '@lehuo/contracts'; +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchPublicActivity, registerActivity } from '../../services/participation'; +Page({ + data: { loading: true, busy: false, message: '', activity: null as ActivityDetail | null }, + onLoad(options: Record) { + if (options['publicId']) void this.load(options['publicId']); + }, + async load(publicId: string) { + try { + const result = await fetchPublicActivity(publicId); + this.setData({ activity: result.activity }); + } catch { + this.setData({ message: '活动暂时无法查看,请稍后重试' }); + } finally { + this.setData({ loading: false }); + } + }, + async register() { + const activity = this.data.activity; + if (!activity || !activity.canRegister) return; + this.setData({ busy: true, message: '' }); + try { + await registerActivity(activity.publicId); + this.setData({ message: '报名已提交,请在志愿者中心查看审核进度。' }); + await this.load(activity.publicId); + } catch (error) { + this.setData({ + message: error instanceof MiniProgramApiError ? error.message : '报名失败,请稍后重试', + }); + } finally { + this.setData({ busy: false }); + } + }, + volunteerCenter() { + void wx.navigateTo({ url: '/pages/volunteer-center/volunteer-center' }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxml b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxml new file mode 100644 index 0000000..18a2134 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxml @@ -0,0 +1 @@ +正在加载…{{message}}{{activity.statusLabel}}{{activity.title}}{{activity.summary}}时间与地点{{activity.startAt}} 至 {{activity.endAt}}{{activity.locationText}}报名截止:{{activity.registrationDeadline}}活动介绍{{activity.content}}{{message}} diff --git a/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxss b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxss new file mode 100644 index 0000000..e5418df --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/activity-detail/activity-detail.wxss @@ -0,0 +1 @@ +@import '../../app.wxss'; diff --git a/apps/miniprogram/miniprogram/pages/application-detail/application-detail.json b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.json new file mode 100644 index 0000000..ef90fe3 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "服务进度" +} diff --git a/apps/miniprogram/miniprogram/pages/application-detail/application-detail.ts b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.ts new file mode 100644 index 0000000..08b2363 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.ts @@ -0,0 +1,56 @@ +import type { ServiceApplicationDetail } from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchServiceApplication } from '../../services/service-applications'; +import { formatChinaDateTime } from '../../utils/date'; + +type DisplayApplication = ServiceApplicationDetail & { + history: Array; +}; + +Page({ + data: { + status: 'loading', + publicId: '', + errorMessage: '', + application: null as DisplayApplication | null, + }, + + onLoad(options: Record) { + this.setData({ publicId: options.publicId ?? '' }); + void this.loadApplication(); + }, + + async loadApplication() { + if (!this.data.publicId) { + this.setData({ status: 'error', errorMessage: '申请信息不完整,请返回重试' }); + return; + } + this.setData({ status: 'loading', errorMessage: '' }); + try { + const result = await fetchServiceApplication(this.data.publicId); + this.setData({ + status: 'success', + application: { + ...result.application, + history: result.application.history.map((item) => ({ + ...item, + createdAtText: formatChinaDateTime(item.createdAt), + })), + }, + }); + } catch (error) { + const message = + error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '暂时无法读取服务进度'; + this.setData({ status: 'error', errorMessage: message }); + } + }, + + retryLoad() { + void this.loadApplication(); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxml b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxml new file mode 100644 index 0000000..e2a5768 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxml @@ -0,0 +1,26 @@ + + 正在读取服务进度… + 暂时无法读取服务进度{{errorMessage}} + + + {{application.serviceCategoryLabel}}{{application.beneficiaryName}}的服务申请 + {{application.statusLabel}} + 服务编号:{{application.applicationNo}} + + + 申请信息 + 申请关系{{application.applyModeLabel}} + 联系电话{{application.contactMobileMasked}} + 所在地区{{application.regionCode}} + 需求说明{{application.description}} + + + 服务进度 + + + {{item.statusLabel}}{{item.reasonLabel}}{{item.createdAtText}} + + 工作人员会根据实际情况与您联系 + + + diff --git a/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxss b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxss new file mode 100644 index 0000000..435d69c --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-detail/application-detail.wxss @@ -0,0 +1,108 @@ +.detail-summary { + display: flex; + margin-bottom: 24rpx; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 20rpx; +} +.detail-title { + display: block; + margin-top: 8rpx; + font-size: 40rpx; + font-weight: 600; +} +.application-meta { + width: 100%; + color: var(--color-text-secondary); + font-size: 27rpx; + font-variant-numeric: tabular-nums; +} +.detail-card { + margin-bottom: 24rpx; +} +.form-title { + display: block; + margin-bottom: 20rpx; + font-size: 36rpx; + font-weight: 600; +} +.summary-row { + display: flex; + min-height: 80rpx; + padding: 14rpx 0; + align-items: center; + justify-content: space-between; + gap: 24rpx; + border-top: 2rpx solid #ebedf0; +} +.summary-row text:first-child { + color: var(--color-text-secondary); +} +.description-summary { + display: flex; + padding-top: 24rpx; + flex-direction: column; + gap: 12rpx; + border-top: 2rpx solid #ebedf0; + line-height: 1.7; +} +.summary-label { + color: var(--color-text-secondary); + font-weight: 600; +} +.timeline-item, +.timeline-next { + position: relative; + display: flex; + min-height: 128rpx; + gap: 24rpx; +} +.timeline-item::before { + position: absolute; + top: 30rpx; + bottom: -8rpx; + left: 11rpx; + width: 4rpx; + content: ''; + background: #ffd3c1; +} +.timeline-dot { + position: relative; + z-index: 1; + width: 26rpx; + height: 26rpx; + margin-top: 10rpx; + flex: 0 0 26rpx; + border: 6rpx solid #ffe6dc; + border-radius: 50%; + background: var(--color-primary); + box-sizing: border-box; +} +.timeline-dot--muted { + background: #aeb3bb; + border-color: #eceef1; +} +.timeline-content { + display: flex; + flex-direction: column; +} +.timeline-title { + font-weight: 600; +} +.timeline-note { + margin-top: 8rpx; + color: var(--color-text-secondary); + font-size: 28rpx; +} +.timeline-time { + margin-top: 8rpx; + color: #7b818b; + font-size: 25rpx; + font-variant-numeric: tabular-nums; +} +.timeline-next { + min-height: 72rpx; + color: var(--color-text-secondary); + font-size: 28rpx; +} diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.json b/apps/miniprogram/miniprogram/pages/application-form/application-form.json new file mode 100644 index 0000000..dd35ed1 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "服务申请" +} diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.ts b/apps/miniprogram/miniprogram/pages/application-form/application-form.ts new file mode 100644 index 0000000..36292f9 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.ts @@ -0,0 +1,216 @@ +import type { + ApplicationApplyMode, + CreateServiceApplicationRequest, + ServiceCategory, + ServiceCategoryOption, +} from '@lehuo/contracts'; +import { + APPLICATION_APPLY_MODE_LABELS, + APPLICATION_APPLY_MODES, + SERVICE_CATEGORIES, + SERVICE_CATEGORY_LABELS, +} from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { + createServiceApplication, + fetchServiceCategories, +} from '../../services/service-applications'; +import { createClientRequestId } from '../../utils/request-id'; + +const TOTAL_STEPS = 5; + +function isServiceCategory(value: string): value is ServiceCategory { + return (SERVICE_CATEGORIES as readonly string[]).includes(value); +} + +function isApplyMode(value: string): value is ApplicationApplyMode { + return (APPLICATION_APPLY_MODES as readonly string[]).includes(value); +} + +Page({ + data: { + step: 1, + totalSteps: TOTAL_STEPS, + loading: true, + submitting: false, + errorMessage: '', + categories: [] as ServiceCategoryOption[], + modeOptions: APPLICATION_APPLY_MODES.map((code) => ({ + code, + label: APPLICATION_APPLY_MODE_LABELS[code], + })), + clientRequestId: createClientRequestId(), + serviceCategory: '', + applyMode: '', + beneficiaryName: '', + contactMobile: '', + regionCode: '', + description: '', + preferredContactTime: '', + needsHomeVisit: false, + mobilityDifficulty: false, + privacyConsent: false, + serviceCategoryLabel: '', + applyModeLabel: '', + contactMobileMasked: '', + }, + + onLoad(options: Record) { + if (options.mode && APPLICATION_APPLY_MODES.includes(options.mode as ApplicationApplyMode)) { + const mode = options.mode as ApplicationApplyMode; + this.setData({ applyMode: mode, applyModeLabel: APPLICATION_APPLY_MODE_LABELS[mode] }); + } + void this.loadCategories(); + }, + + async loadCategories() { + this.setData({ loading: true, errorMessage: '' }); + try { + const result = await fetchServiceCategories(); + this.setData({ categories: result.items, loading: false }); + } catch (error) { + this.setData({ loading: false, errorMessage: this.errorText(error) }); + } + }, + + retryLoad() { + void this.loadCategories(); + }, + + selectCategory(event: WechatMiniprogram.TouchEvent) { + const code = event.currentTarget.dataset.code as ServiceCategory; + this.setData({ + serviceCategory: code, + serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code], + clientRequestId: createClientRequestId(), + errorMessage: '', + }); + }, + + selectMode(event: WechatMiniprogram.TouchEvent) { + const code = event.currentTarget.dataset.code as ApplicationApplyMode; + this.setData({ + applyMode: code, + applyModeLabel: APPLICATION_APPLY_MODE_LABELS[code], + clientRequestId: createClientRequestId(), + errorMessage: '', + }); + }, + + updateBeneficiaryName(event: WechatMiniprogram.Input) { + this.updateField({ beneficiaryName: event.detail.value }); + }, + + updateContactMobile(event: WechatMiniprogram.Input) { + this.updateField({ contactMobile: event.detail.value }); + }, + + updateRegionCode(event: WechatMiniprogram.Input) { + this.updateField({ regionCode: event.detail.value }); + }, + + updateDescription(event: WechatMiniprogram.Input) { + this.updateField({ description: event.detail.value }); + }, + + updatePreferredContactTime(event: WechatMiniprogram.Input) { + this.updateField({ preferredContactTime: event.detail.value }); + }, + + updateNeedsHomeVisit(event: WechatMiniprogram.SwitchChange) { + this.updateField({ needsHomeVisit: event.detail.value }); + }, + + updateMobilityDifficulty(event: WechatMiniprogram.SwitchChange) { + this.updateField({ mobilityDifficulty: event.detail.value }); + }, + + updatePrivacyConsent(event: WechatMiniprogram.CheckboxGroupChange) { + this.setData({ privacyConsent: event.detail.value.includes('accepted'), errorMessage: '' }); + }, + + nextStep() { + const message = this.validateStep(this.data.step); + if (message) { + this.setData({ errorMessage: message }); + return; + } + const next = Math.min(TOTAL_STEPS, this.data.step + 1); + this.setData({ + step: next, + errorMessage: '', + contactMobileMasked: this.data.contactMobile.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'), + }); + }, + + previousStep() { + this.setData({ step: Math.max(1, this.data.step - 1), errorMessage: '' }); + }, + + async submitApplication() { + if (!this.data.privacyConsent) { + this.setData({ errorMessage: '请阅读并同意隐私授权后再提交' }); + return; + } + if ( + !isServiceCategory(this.data.serviceCategory) || + !isApplyMode(this.data.applyMode) || + this.data.submitting + ) + return; + this.setData({ submitting: true, errorMessage: '' }); + const input: CreateServiceApplicationRequest = { + clientRequestId: this.data.clientRequestId, + serviceCategory: this.data.serviceCategory, + applyMode: this.data.applyMode, + beneficiaryName: this.data.beneficiaryName.trim(), + contactMobile: this.data.contactMobile.trim(), + regionCode: this.data.regionCode.trim(), + description: this.data.description.trim(), + ...(this.data.preferredContactTime.trim() + ? { preferredContactTime: this.data.preferredContactTime.trim() } + : {}), + needsHomeVisit: this.data.needsHomeVisit, + mobilityDifficulty: this.data.mobilityDifficulty, + privacyConsent: true, + }; + try { + const result = await createServiceApplication(input); + void wx.redirectTo({ + url: `/pages/application-success/application-success?publicId=${encodeURIComponent(result.application.publicId)}`, + }); + } catch (error) { + this.setData({ submitting: false, errorMessage: this.errorText(error) }); + } + }, + + updateField(values: Record) { + this.setData({ + ...values, + clientRequestId: createClientRequestId(), + errorMessage: '', + }); + }, + + validateStep(step: number): string { + if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型'; + if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系'; + if (step === 3) { + if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼'; + if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码'; + if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道'; + } + if (step === 4 && this.data.description.trim().length < 10) { + return '请至少用 10 个字描述目前的困难或希望得到的帮助'; + } + return ''; + }, + + errorText(error: unknown): string { + if (error instanceof MiniProgramApiError) { + return `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`; + } + return error instanceof Error ? error.message : '提交失败,请稍后重试,已填写内容不会清空'; + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml new file mode 100644 index 0000000..d094786 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml @@ -0,0 +1,111 @@ + + + + 第 {{step}} 步,共 {{totalSteps}} 步 + {{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}} + + + + + + + 正在读取服务类型… + + + + 暂时无法读取服务类型 + {{errorMessage}} + + + + + + 需要什么帮助? + 请选择最接近的一项,工作人员后续会进一步了解。 + + + + + 帮谁申请? + 请选择您与服务对象的关系。 + + + + + 留下联系方式 + 只填写联系服务所必需的信息,不需要精确门牌地址。 + 服务对象姓名或称呼 * + + 联系电话 * + + 所在地区 * + + 期望联系时间(可选) + + + + + 简单描述需要的帮助 + 请勿填写身份证号码、银行卡号或精确门牌地址。 + + {{description.length}} / 1000 + + 是否希望上门服务工作人员会根据情况确认 + + + + 行动是否不方便帮助工作人员做好联系准备 + + + 如遇危及生命安全的紧急情况,请立即联系 110 或 120。 + + + + 确认申请信息 + 提交前请确认,联系方式仅供授权工作人员提供服务使用。 + + 服务类型{{serviceCategoryLabel}} + 申请关系{{applyModeLabel}} + 服务对象{{beneficiaryName}} + 联系电话{{contactMobileMasked}} + 所在地区{{regionCode}} + + 需求说明{{description}} + + + + + + {{errorMessage}} + + + + + + + diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss new file mode 100644 index 0000000..31ca8a1 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss @@ -0,0 +1,237 @@ +.progress-card { + margin-bottom: 24rpx; + padding: 24rpx 28rpx; + border-radius: 24rpx; + background: #ffffff; +} + +.progress-meta { + display: flex; + margin-bottom: 16rpx; + justify-content: space-between; + color: var(--color-text-secondary); + font-size: 26rpx; +} + +.progress-track { + height: 12rpx; + overflow: hidden; + border-radius: 999rpx; + background: #eceef1; +} + +.progress-value { + height: 100%; + border-radius: inherit; + background: var(--color-primary); + transition: width 180ms ease; +} + +.step-1 { + width: 20%; +} +.step-2 { + width: 40%; +} +.step-3 { + width: 60%; +} +.step-4 { + width: 80%; +} +.step-5 { + width: 100%; +} + +.form-title, +.form-hint { + display: block; +} + +.form-title { + font-size: 40rpx; + font-weight: 600; +} + +.form-hint { + margin: 12rpx 0 28rpx; + color: var(--color-text-secondary); + font-size: 28rpx; + line-height: 1.7; +} + +.selection-card { + display: flex; + width: 100%; + min-height: 128rpx; + margin: 0 0 20rpx; + padding: 24rpx; + align-items: center; + gap: 20rpx; + border: 2rpx solid var(--color-border); + border-radius: 24rpx; + color: var(--color-text); + background: #ffffff; + text-align: left; +} + +.selection-card--selected { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} + +.selection-card--simple { + min-height: 96rpx; +} + +.selection-content { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.selection-title { + font-size: 34rpx; + font-weight: 600; +} + +.selection-description { + margin-top: 6rpx; + color: var(--color-text-secondary); + font-size: 27rpx; + line-height: 1.5; +} + +.selection-status { + color: var(--color-primary-pressed); + font-size: 26rpx; +} + +.field-label { + display: block; + margin: 28rpx 0 12rpx; + font-weight: 600; +} + +.field-input, +.field-textarea { + width: 100%; + border: 2rpx solid #cfd2d8; + border-radius: 20rpx; + background: #ffffff; + box-sizing: border-box; + font-size: 32rpx; +} + +.field-input { + min-height: 96rpx; + padding: 20rpx 24rpx; +} + +.field-textarea { + height: 280rpx; + padding: 24rpx; + line-height: 1.7; +} + +.character-count { + display: block; + margin-top: 8rpx; + color: var(--color-text-secondary); + font-size: 26rpx; + text-align: right; +} + +.switch-row { + display: flex; + min-height: 112rpx; + margin-top: 20rpx; + padding: 20rpx 0; + align-items: center; + justify-content: space-between; + border-top: 2rpx solid #ebedf0; +} + +.switch-title, +.switch-hint { + display: block; +} + +.switch-title { + font-weight: 600; +} +.switch-hint { + margin-top: 6rpx; + color: var(--color-text-secondary); + font-size: 26rpx; +} + +.emergency-note { + margin-top: 24rpx; + padding: 20rpx; + border-radius: 16rpx; + color: #704c00; + background: #fff6d8; + font-size: 28rpx; + line-height: 1.6; +} + +.summary-list { + border-top: 2rpx solid #ebedf0; +} + +.summary-row { + display: flex; + min-height: 88rpx; + padding: 16rpx 0; + align-items: center; + justify-content: space-between; + gap: 24rpx; + border-bottom: 2rpx solid #ebedf0; +} + +.summary-row text:first-child { + color: var(--color-text-secondary); +} +.summary-row text:last-child { + text-align: right; +} + +.description-summary { + display: flex; + margin-top: 24rpx; + flex-direction: column; + gap: 12rpx; + line-height: 1.7; +} + +.summary-label { + color: var(--color-text-secondary); + font-weight: 600; +} + +.consent-row { + display: flex; + min-height: 96rpx; + margin-top: 28rpx; + align-items: flex-start; + gap: 16rpx; + font-size: 28rpx; + line-height: 1.65; +} + +.form-actions { + display: flex; + gap: 20rpx; +} + +.action-button { + flex: 1; +} + +@media (prefers-reduced-motion: reduce) { + .progress-value { + transition: none; + } +} diff --git a/apps/miniprogram/miniprogram/pages/application-success/application-success.json b/apps/miniprogram/miniprogram/pages/application-success/application-success.json new file mode 100644 index 0000000..0578d5d --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-success/application-success.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "提交成功" +} diff --git a/apps/miniprogram/miniprogram/pages/application-success/application-success.ts b/apps/miniprogram/miniprogram/pages/application-success/application-success.ts new file mode 100644 index 0000000..512872e --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-success/application-success.ts @@ -0,0 +1,61 @@ +import type { ServiceApplicationDetail } from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchServiceApplication } from '../../services/service-applications'; +import { formatChinaDateTime } from '../../utils/date'; + +type DisplayApplication = ServiceApplicationDetail & { submittedAtText: string }; + +Page({ + data: { + loading: true, + errorMessage: '', + publicId: '', + application: null as DisplayApplication | null, + }, + + onLoad(options: Record) { + this.setData({ publicId: options.publicId ?? '' }); + void this.loadApplication(); + }, + + async loadApplication() { + if (!this.data.publicId) { + this.setData({ loading: false, errorMessage: '申请信息不完整,请前往“我的申请”查看' }); + return; + } + this.setData({ loading: true, errorMessage: '' }); + try { + const result = await fetchServiceApplication(this.data.publicId); + this.setData({ + application: { + ...result.application, + submittedAtText: formatChinaDateTime(result.application.submittedAt), + }, + loading: false, + }); + } catch (error) { + const message = + error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '暂时无法读取申请信息'; + this.setData({ loading: false, errorMessage: message }); + } + }, + + retryLoad() { + void this.loadApplication(); + }, + + viewProgress() { + void wx.redirectTo({ + url: `/pages/application-detail/application-detail?publicId=${encodeURIComponent(this.data.publicId)}`, + }); + }, + + backHome() { + void wx.reLaunch({ url: '/pages/index/index' }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/application-success/application-success.wxml b/apps/miniprogram/miniprogram/pages/application-success/application-success.wxml new file mode 100644 index 0000000..b080115 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-success/application-success.wxml @@ -0,0 +1,19 @@ + + 正在确认提交结果… + + 暂时无法读取申请结果{{errorMessage}} + + + + + 申请已提交 + 乐活公益工作人员将根据实际情况与您联系,请留意来电。 + + 服务编号{{application.applicationNo}} + 提交时间{{application.submittedAtText}} + 当前状态{{application.statusLabel}} + + + + + diff --git a/apps/miniprogram/miniprogram/pages/application-success/application-success.wxss b/apps/miniprogram/miniprogram/pages/application-success/application-success.wxss new file mode 100644 index 0000000..8ad5687 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-success/application-success.wxss @@ -0,0 +1,47 @@ +.success-card { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: center; +} + +.success-mark { + display: flex; + width: 112rpx; + height: 112rpx; + margin: 12rpx auto 24rpx; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #ffffff; + background: var(--color-success); + font-size: 64rpx; + font-weight: 600; +} + +.success-title { + font-size: 44rpx; + font-weight: 600; +} +.success-message { + margin-top: 16rpx; + color: var(--color-text-secondary); + line-height: 1.7; +} +.result-list { + margin: 32rpx 0 8rpx; + text-align: left; +} +.result-list > view { + display: flex; + min-height: 88rpx; + padding: 16rpx 0; + align-items: center; + justify-content: space-between; + gap: 20rpx; + border-top: 2rpx solid #ebedf0; +} +.result-value { + text-align: right; + font-variant-numeric: tabular-nums; +} diff --git a/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.json b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.json new file mode 100644 index 0000000..1ca55f7 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "协作任务详情" +} diff --git a/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.ts b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.ts new file mode 100644 index 0000000..fd2e867 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.ts @@ -0,0 +1,128 @@ +import { + SERVICE_RECORD_TYPES, + SERVICE_RECORD_TYPE_LABELS, + type ServiceCaseDetail, +} from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { + createAssignedCaseRecord, + fetchAssignedCase, + fetchAssignedFileUrl, +} from '../../services/assigned-cases'; +import { formatChinaDateTime } from '../../utils/date'; +import { createClientRequestId } from '../../utils/request-id'; + +const recordTypes = SERVICE_RECORD_TYPES.map((value) => ({ + value, + label: SERVICE_RECORD_TYPE_LABELS[value], +})); + +Page({ + data: { + publicId: '', + state: 'loading', + message: '', + serviceCase: null as ServiceCaseDetail | null, + records: [] as Array>, + recordTypes, + recordTypeIndex: 0, + content: '', + result: '', + followUpSuggestion: '', + submitting: false, + }, + + onLoad(options: Record) { + this.setData({ publicId: options['id'] ?? '' }); + void this.load(); + }, + + async load() { + if (!this.data.publicId) { + this.setData({ state: 'error', message: '任务标识无效,请返回重试' }); + return; + } + this.setData({ state: 'loading', message: '' }); + try { + const result = await fetchAssignedCase(this.data.publicId); + this.setData({ + state: 'success', + serviceCase: result.serviceCase, + records: result.serviceCase.records.map((record) => ({ + ...record, + serviceTimeLabel: formatChinaDateTime(record.serviceStartAt), + })), + }); + } catch (error) { + this.setData({ state: 'error', message: this.errorMessage(error) }); + } + }, + + errorMessage(error: unknown) { + return error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '操作失败,请稍后重试'; + }, + + changeRecordType(event: WechatMiniprogram.PickerChange) { + this.setData({ recordTypeIndex: Number(event.detail.value) }); + }, + inputContent(event: WechatMiniprogram.Input) { + this.setData({ content: event.detail.value }); + }, + inputResult(event: WechatMiniprogram.Input) { + this.setData({ result: event.detail.value }); + }, + inputFollowUp(event: WechatMiniprogram.Input) { + this.setData({ followUpSuggestion: event.detail.value }); + }, + + async submitRecord() { + const serviceCase = this.data.serviceCase; + if (!serviceCase || serviceCase.status !== 'IN_SERVICE') return; + if (this.data.content.trim().length < 2 || this.data.result.trim().length < 2) { + this.setData({ message: '请填写完整的服务内容和服务结果。' }); + return; + } + this.setData({ submitting: true, message: '' }); + try { + const selectedType = recordTypes[this.data.recordTypeIndex]?.value ?? 'OTHER'; + await createAssignedCaseRecord(serviceCase.publicId, { + clientRequestId: createClientRequestId(), + serviceStartAt: new Date().toISOString(), + serviceType: selectedType, + content: this.data.content.trim(), + result: this.data.result.trim(), + ...(this.data.followUpSuggestion.trim() + ? { followUpSuggestion: this.data.followUpSuggestion.trim() } + : {}), + beneficiaryConfirmStatus: 'NOT_REQUESTED', + attachmentPublicIds: [], + }); + this.setData({ + content: '', + result: '', + followUpSuggestion: '', + message: '服务记录已安全保存。', + }); + await this.load(); + } catch (error) { + this.setData({ message: this.errorMessage(error) }); + } finally { + this.setData({ submitting: false }); + } + }, + + async previewFile(event: WechatMiniprogram.BaseEvent) { + const publicId = event.currentTarget.dataset['id'] as string; + try { + const result = await fetchAssignedFileUrl(publicId); + await wx.previewImage({ urls: [result.downloadUrl], current: result.downloadUrl }); + } catch (error) { + this.setData({ message: this.errorMessage(error) }); + } + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxml b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxml new file mode 100644 index 0000000..031e595 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxml @@ -0,0 +1,12 @@ + + + 任务加载失败{{message}} + + {{serviceCase.caseNo}}{{serviceCase.beneficiaryDisplayName}}{{serviceCase.statusLabel}}负责人:{{serviceCase.ownerDisplayName}} + 只展示完成本次服务所需的最少信息。请勿转发、截图传播或用于其他目的;本次查看已记录审计。 + 服务安排所在地区{{serviceCase.regionCode}}联系电话{{serviceCase.beneficiaryMobileMasked || '未留存'}}服务计划{{serviceCase.summary}} + 等待负责人开始服务工单开始后,您可以填写本次服务记录。 + 记录本次服务{{recordTypes[recordTypeIndex].label}} ⌄{{message}} + 历史服务记录暂无服务记录{{item.serviceTypeLabel}}{{item.serviceTimeLabel}}{{item.content}}结果:{{item.result}} + + diff --git a/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxss b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxss new file mode 100644 index 0000000..ed6b96e --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-case-detail/assigned-case-detail.wxss @@ -0,0 +1,147 @@ +.privacy-note { + margin-bottom: 24rpx; + padding: 24rpx; + border-left: 8rpx solid #faad14; + border-radius: 20rpx; + color: #654f17; + background: #fff9e8; + font-size: 27rpx; + line-height: 1.7; +} +.field-label { + display: block; + margin: 24rpx 0 12rpx; + font-weight: 600; +} +.picker-field, +textarea { + width: 100%; + min-height: 88rpx; + padding: 20rpx 24rpx; + border: 2rpx solid var(--color-border); + border-radius: 20rpx; + background: #fff; + box-sizing: border-box; +} +.picker-field { + display: flex; + align-items: center; + justify-content: space-between; +} +textarea { + min-height: 200rpx; + line-height: 1.6; +} +.records { + margin-top: 24rpx; +} +.empty-note { + color: var(--color-text-secondary); +} +.record { + padding: 24rpx 0; + border-top: 2rpx solid var(--color-border); +} +.record-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} +.record-type { + font-weight: 600; +} +.record-time { + color: var(--color-text-secondary); + font-size: 25rpx; +} +.record-text, +.record-result { + display: block; + margin-top: 14rpx; + line-height: 1.65; +} +.record-result { + color: var(--color-text-secondary); +} +.file-link { + min-height: 80rpx; + margin: 14rpx 0 0; + padding: 12rpx 0; + color: #145ea8; + background: transparent; + font-size: 28rpx; + text-align: left; +} +.notice-card { + margin-bottom: 24rpx; + padding: 28rpx; + border-left: 8rpx solid #faad14; + border-radius: 20rpx; + background: #fff9e8; +} +.notice-title, +.notice-text { + display: block; +} +.notice-title { + font-weight: 600; +} +.notice-text { + margin-top: 8rpx; + color: #654f17; + font-size: 28rpx; + line-height: 1.7; +} +.detail-summary { + display: flex; + margin-bottom: 24rpx; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 20rpx; +} +.detail-title { + display: block; + margin-top: 8rpx; + font-size: 40rpx; + font-weight: 600; +} +.application-meta { + width: 100%; + color: var(--color-text-secondary); + font-size: 27rpx; +} +.detail-card { + margin-bottom: 24rpx; +} +.form-title { + display: block; + margin-bottom: 20rpx; + font-size: 36rpx; + font-weight: 600; +} +.summary-row { + display: flex; + min-height: 80rpx; + padding: 14rpx 0; + align-items: center; + justify-content: space-between; + gap: 24rpx; + border-top: 2rpx solid #ebedf0; +} +.summary-row text:first-child { + color: var(--color-text-secondary); +} +.description-summary { + display: flex; + padding-top: 24rpx; + flex-direction: column; + gap: 12rpx; + border-top: 2rpx solid #ebedf0; + line-height: 1.7; +} +.summary-label { + color: var(--color-text-secondary); + font-weight: 600; +} diff --git a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.json b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.json new file mode 100644 index 0000000..7a0e23d --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的协作任务" +} diff --git a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.ts b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.ts new file mode 100644 index 0000000..912f180 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.ts @@ -0,0 +1,53 @@ +import type { ServiceCaseListItem, ServiceCaseStatus } from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchAssignedCases } from '../../services/assigned-cases'; +import { formatChinaDateTime } from '../../utils/date'; + +const statuses: Array<{ value: '' | ServiceCaseStatus; label: string }> = [ + { value: '', label: '全部任务' }, + { value: 'ASSIGNED', label: '待开始' }, + { value: 'IN_SERVICE', label: '服务中' }, +]; + +Page({ + data: { + state: 'loading', + message: '', + items: [] as ServiceCaseListItem[], + statuses, + statusIndex: 0, + }, + onShow() { + void this.load(); + }, + async load() { + this.setData({ state: 'loading', message: '' }); + try { + const selected = statuses[this.data.statusIndex]; + const result = await fetchAssignedCases(selected?.value || undefined); + this.setData({ state: result.items.length ? 'success' : 'empty', items: result.items }); + } catch (error) { + const message = + error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '任务加载失败'; + this.setData({ state: 'error', message }); + } + }, + changeStatus(event: WechatMiniprogram.PickerChange) { + this.setData({ statusIndex: Number(event.detail.value) }); + void this.load(); + }, + openCase(event: WechatMiniprogram.BaseEvent) { + const publicId = event.currentTarget.dataset['id'] as string; + void wx.navigateTo({ + url: `/pages/assigned-case-detail/assigned-case-detail?id=${encodeURIComponent(publicId)}`, + }); + }, + formatDate(value: string) { + return formatChinaDateTime(value); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml new file mode 100644 index 0000000..a579731 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml @@ -0,0 +1,8 @@ + + 我的协作任务仅展示已指派给您的必要服务信息。 + 筛选:{{statuses[statusIndex].label}} ⌄ + + 任务加载失败{{message}} + 暂无协作任务当工作人员将服务工单指派给您后,会显示在这里。 + + diff --git a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxss b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxss new file mode 100644 index 0000000..dacf439 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxss @@ -0,0 +1,75 @@ +.page-heading { + margin: 8rpx 0 24rpx; +} +.page-title, +.page-subtitle, +.case-name, +.case-meta, +.case-link { + display: block; +} +.page-title { + font-size: 44rpx; + font-weight: 600; +} +.page-subtitle { + margin-top: 10rpx; + color: var(--color-text-secondary); + font-size: 28rpx; + line-height: 1.6; +} +.filter-picker { + display: flex; + min-height: 88rpx; + margin-bottom: 24rpx; + padding: 16rpx 24rpx; + align-items: center; + justify-content: space-between; + border: 2rpx solid var(--color-border); + border-radius: 20rpx; + background: #fff; + box-sizing: border-box; +} +.case-card { + display: flex; + width: 100%; + margin: 0 0 24rpx; + padding: 28rpx; + flex-direction: column; + align-items: stretch; + border: 2rpx solid var(--color-border); + border-radius: 28rpx; + color: var(--color-text); + background: #fff; + text-align: left; +} +.case-card:active { + opacity: 0.72; +} +.case-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} +.case-no { + color: var(--color-primary-pressed); + font-size: 27rpx; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.case-name { + margin-top: 22rpx; + font-size: 36rpx; + font-weight: 600; +} +.case-meta { + margin-top: 10rpx; + color: var(--color-text-secondary); + font-size: 27rpx; +} +.case-link { + margin-top: 20rpx; + color: #145ea8; + font-weight: 600; +} diff --git a/apps/miniprogram/miniprogram/pages/content-detail/content-detail.json b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.json new file mode 100644 index 0000000..461b0f9 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "内容详情" } diff --git a/apps/miniprogram/miniprogram/pages/content-detail/content-detail.ts b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.ts new file mode 100644 index 0000000..33a6c26 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.ts @@ -0,0 +1,22 @@ +import type { ContentPostDetail } from '@lehuo/contracts'; +import { fetchPublicContentDetail } from '../../services/content-notifications'; +Page({ + data: { loading: true, message: '', post: null as ContentPostDetail | null }, + onLoad(o) { + const id = o['publicId']; + if (!id) { + this.setData({ loading: false, message: '内容参数无效' }); + return; + } + void this.load(id); + }, + async load(id: string) { + try { + this.setData({ post: (await fetchPublicContentDetail(id)).post }); + } catch (e) { + this.setData({ message: e instanceof Error ? e.message : '内容加载失败' }); + } finally { + this.setData({ loading: false }); + } + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxml b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxml new file mode 100644 index 0000000..a465aeb --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxml @@ -0,0 +1 @@ +加载中…{{message}}
{{post.title}}{{post.summary}}{{post.content}}发布时间:{{post.publishedAt}}
diff --git a/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxss b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxss new file mode 100644 index 0000000..9e40c72 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content-detail/content-detail.wxss @@ -0,0 +1,31 @@ +@import '../../app.wxss'; +.detail-card { + padding: 36rpx; + background: #fff; + border-radius: 24rpx; +} +.detail-title, +.detail-summary, +.detail-content, +.detail-date { + display: block; +} +.detail-title { + font-size: 40rpx; + font-weight: 700; + color: #25282d; +} +.detail-summary { + margin-top: 24rpx; + color: #646a73; +} +.detail-content { + margin-top: 36rpx; + white-space: pre-wrap; + line-height: 1.8; +} +.detail-date { + margin-top: 36rpx; + color: #8b9098; + font-size: 24rpx; +} diff --git a/apps/miniprogram/miniprogram/pages/content/content.json b/apps/miniprogram/miniprogram/pages/content/content.json new file mode 100644 index 0000000..ba6abf3 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content/content.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "公益资讯" } diff --git a/apps/miniprogram/miniprogram/pages/content/content.ts b/apps/miniprogram/miniprogram/pages/content/content.ts new file mode 100644 index 0000000..1282b28 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content/content.ts @@ -0,0 +1,30 @@ +import type { ContentPostListItem } from '@lehuo/contracts'; +import { fetchPublicContent } from '../../services/content-notifications'; +Page({ + data: { + loading: true, + message: '', + type: '', + items: [] as ContentPostListItem[], + }, + onLoad(options) { + this.setData({ type: options['type'] ?? '' }); + void this.load(); + }, + async load() { + this.setData({ loading: true, message: '' }); + try { + const r = await fetchPublicContent(this.data.type || undefined); + this.setData({ items: r.items }); + } catch (e) { + this.setData({ message: e instanceof Error ? e.message : '内容加载失败' }); + } finally { + this.setData({ loading: false }); + } + }, + open(e: WechatMiniprogram.BaseEvent) { + void wx.navigateTo({ + url: `/pages/content-detail/content-detail?publicId=${String(e.currentTarget.dataset['id'])}`, + }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/content/content.wxml b/apps/miniprogram/miniprogram/pages/content/content.wxml new file mode 100644 index 0000000..4f239f9 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content/content.wxml @@ -0,0 +1 @@ +公益资讯所有内容均经审核后发布正在加载…{{message}}暂无已发布内容 diff --git a/apps/miniprogram/miniprogram/pages/content/content.wxss b/apps/miniprogram/miniprogram/pages/content/content.wxss new file mode 100644 index 0000000..8a08799 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/content/content.wxss @@ -0,0 +1,10 @@ +@import '../../app.wxss'; +.card-meta { + display: block; + margin-top: 12rpx; + color: #646a73; + font-size: 24rpx; +} +.state-card { + margin-top: 24rpx; +} diff --git a/apps/miniprogram/miniprogram/pages/index/index.json b/apps/miniprogram/miniprogram/pages/index/index.json new file mode 100644 index 0000000..99770ef --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/index/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "乐活公益" +} diff --git a/apps/miniprogram/miniprogram/pages/index/index.ts b/apps/miniprogram/miniprogram/pages/index/index.ts new file mode 100644 index 0000000..94696ab --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/index/index.ts @@ -0,0 +1,78 @@ +import { AUTH_READY_MESSAGE, MINI_PROGRAM_TITLE } from '../../constants/app-info'; +import { MiniProgramApiError } from '../../services/api-client'; +import { loginWithWechat, restoreCurrentSession } from '../../services/auth'; + +Page({ + data: { + authStatus: 'loading', + message: '正在安全登录,请稍候…', + title: MINI_PROGRAM_TITLE, + canCollaborate: false, + }, + + onLoad() { + void this.authenticate(); + }, + + async authenticate() { + this.setData({ authStatus: 'loading', message: '正在安全登录,请稍候…' }); + try { + const session = await restoreCurrentSession(); + const user = session?.user ?? (await loginWithWechat()).user; + this.setData({ + authStatus: 'success', + message: AUTH_READY_MESSAGE, + canCollaborate: user.permissions.includes('assigned_cases:read'), + }); + } catch (error) { + const message = + error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '登录失败,请稍后重试'; + this.setData({ authStatus: 'error', message }); + } + }, + + retryLogin() { + void this.authenticate(); + }, + + applyForSelf() { + void wx.navigateTo({ url: '/pages/application-form/application-form?mode=SELF' }); + }, + + applyForFamily() { + void wx.navigateTo({ url: '/pages/application-form/application-form?mode=FAMILY' }); + }, + + viewMyApplications() { + void wx.navigateTo({ url: '/pages/my-applications/my-applications' }); + }, + + viewAssignedCases() { + void wx.navigateTo({ url: '/pages/assigned-cases/assigned-cases' }); + }, + + viewProjects() { + void wx.navigateTo({ url: '/pages/projects/projects' }); + }, + + viewActivities() { + void wx.navigateTo({ url: '/pages/activities/activities' }); + }, + + viewVolunteerCenter() { + void wx.navigateTo({ url: '/pages/volunteer-center/volunteer-center' }); + }, + viewContent() { + void wx.navigateTo({ url: '/pages/content/content' }); + }, + viewOrganization() { + void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' }); + }, + viewNotifications() { + void wx.navigateTo({ url: '/pages/notifications/notifications' }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/index/index.wxml b/apps/miniprogram/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..5da5b83 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/index/index.wxml @@ -0,0 +1,63 @@ + + + 乐活公益 · 暖心服务平台 + 用爱连接,让爱传递 + 为老人、妇女儿童、残障人士和困难家庭提供公益服务支持。 + + + + + + 正在安全登录,请稍候… + + + + 暂时无法登录 + {{message}} + + + + + + 需要哪种帮助? + 选择后只需几步即可提交 + + + + + + 参与公益 + 了解项目,报名志愿活动 + + + + + 了解我们经审核发布的公益内容 + + + + + + 温馨提示 + 提交后工作人员会根据实际服务能力与您联系。遇到危及生命安全的紧急情况,请及时联系 110 或 120。 + + + diff --git a/apps/miniprogram/miniprogram/pages/index/index.wxss b/apps/miniprogram/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..d50555d --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/index/index.wxss @@ -0,0 +1,115 @@ +.brand-card { + margin-bottom: 32rpx; + padding: 40rpx 32rpx; + border-radius: 32rpx; + color: #7a2e12; + background: linear-gradient(135deg, #fff7f0, #ffe2d2); +} + +.title { + display: block; + margin-top: 12rpx; + font-size: 48rpx; + font-weight: 600; + line-height: 1.3; +} + +.message { + display: block; + margin-top: 16rpx; + color: #653b2c; + line-height: 1.7; +} + +.section-heading { + display: flex; + margin: 40rpx 0 20rpx; + flex-direction: column; + gap: 8rpx; +} + +.section-title { + font-size: 40rpx; + font-weight: 600; +} + +.section-note, +.action-description { + color: var(--color-text-secondary); + font-size: 28rpx; +} + +.action-card { + display: flex; + min-height: 152rpx; + margin: 0 0 24rpx; + padding: 28rpx 32rpx; + flex-direction: column; + align-items: flex-start; + justify-content: center; + border: 2rpx solid var(--color-border); + border-radius: 28rpx; + color: var(--color-text); + background: #ffffff; + text-align: left; +} + +.action-card--primary { + border-color: #ffb08e; + background: var(--color-primary-soft); +} + +.action-card:active, +.progress-link:active { + opacity: 0.72; +} + +.action-title { + font-size: 36rpx; + font-weight: 600; +} + +.action-description { + margin-top: 8rpx; +} + +.progress-link { + display: flex; + min-height: 104rpx; + margin: 8rpx 0 32rpx; + padding: 20rpx 28rpx; + align-items: center; + justify-content: space-between; + border-radius: 24rpx; + color: #145ea8; + background: #eaf4ff; + font-weight: 600; +} +.progress-link--volunteer { + margin-top: -16rpx; + color: #237a2b; + background: #edf8ee; +} + +.notice-card { + padding: 28rpx; + border-left: 8rpx solid #faad14; + border-radius: 20rpx; + background: #fff9e8; +} + +.notice-title, +.notice-text { + display: block; +} + +.notice-title { + font-weight: 600; +} + +.notice-text { + margin-top: 8rpx; + color: #654f17; + font-size: 28rpx; + line-height: 1.7; +} diff --git a/apps/miniprogram/miniprogram/pages/my-applications/my-applications.json b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.json new file mode 100644 index 0000000..fd31550 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的申请" +} diff --git a/apps/miniprogram/miniprogram/pages/my-applications/my-applications.ts b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.ts new file mode 100644 index 0000000..f908ad0 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.ts @@ -0,0 +1,56 @@ +import type { ServiceApplicationListItem } from '@lehuo/contracts'; + +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchMyServiceApplications } from '../../services/service-applications'; +import { formatChinaDateTime } from '../../utils/date'; + +type DisplayListItem = ServiceApplicationListItem & { submittedAtText: string }; + +Page({ + data: { + status: 'loading', + errorMessage: '', + items: [] as DisplayListItem[], + }, + + onShow() { + void this.loadApplications(); + }, + + async loadApplications() { + this.setData({ status: 'loading', errorMessage: '' }); + try { + const result = await fetchMyServiceApplications(); + this.setData({ + status: result.items.length ? 'success' : 'empty', + items: result.items.map((item) => ({ + ...item, + submittedAtText: formatChinaDateTime(item.submittedAt), + })), + }); + } catch (error) { + const message = + error instanceof MiniProgramApiError && error.requestId + ? `${error.message}(编号:${error.requestId})` + : error instanceof Error + ? error.message + : '暂时无法读取申请'; + this.setData({ status: 'error', errorMessage: message }); + } + }, + + openApplication(event: WechatMiniprogram.TouchEvent) { + const publicId = event.currentTarget.dataset.publicId as string; + void wx.navigateTo({ + url: `/pages/application-detail/application-detail?publicId=${encodeURIComponent(publicId)}`, + }); + }, + + startApplication() { + void wx.navigateTo({ url: '/pages/application-form/application-form' }); + }, + + retryLoad() { + void this.loadApplications(); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxml b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxml new file mode 100644 index 0000000..436d89c --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxml @@ -0,0 +1,21 @@ + + 我的服务申请查看已提交的需求和工作人员处理进度 + + + 您还没有提交过服务申请如果您或家人需要帮助,可以从这里开始。 + + + + 暂时无法读取申请{{errorMessage}} + + + + + + diff --git a/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxss b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxss new file mode 100644 index 0000000..b6f740f --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/my-applications/my-applications.wxss @@ -0,0 +1,59 @@ +.page-heading { + margin: 8rpx 0 28rpx; +} +.page-title, +.page-subtitle { + display: block; +} +.page-title { + font-size: 44rpx; + font-weight: 600; +} +.page-subtitle { + margin-top: 10rpx; + color: var(--color-text-secondary); + font-size: 28rpx; + line-height: 1.6; +} +.application-card { + display: flex; + width: 100%; + margin: 0 0 24rpx; + padding: 28rpx; + flex-direction: column; + align-items: stretch; + border: 2rpx solid var(--color-border); + border-radius: 28rpx; + color: var(--color-text); + background: #ffffff; + text-align: left; +} +.application-card:active { + opacity: 0.72; +} +.application-card__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} +.application-category { + color: var(--color-primary-pressed); + font-weight: 600; +} +.application-name { + margin-top: 24rpx; + font-size: 36rpx; + font-weight: 600; +} +.application-meta { + margin-top: 10rpx; + color: var(--color-text-secondary); + font-size: 27rpx; + font-variant-numeric: tabular-nums; +} +.application-link { + margin-top: 20rpx; + color: #145ea8; + font-weight: 600; +} diff --git a/apps/miniprogram/miniprogram/pages/notifications/notifications.json b/apps/miniprogram/miniprogram/pages/notifications/notifications.json new file mode 100644 index 0000000..4273e29 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/notifications/notifications.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "我的消息" } diff --git a/apps/miniprogram/miniprogram/pages/notifications/notifications.ts b/apps/miniprogram/miniprogram/pages/notifications/notifications.ts new file mode 100644 index 0000000..d5f6b41 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/notifications/notifications.ts @@ -0,0 +1,79 @@ +import type { NotificationItem, NotificationPreferenceItem } from '@lehuo/contracts'; +import { + fetchNotificationPreferences, + fetchNotifications, + readNotification, + updateNotificationPreferences, +} from '../../services/content-notifications'; +Page({ + data: { + loading: true, + message: '', + items: [] as NotificationItem[], + preferences: [] as NotificationPreferenceItem[], + adapterConfigured: false, + }, + onShow() { + void this.load(); + }, + async load() { + this.setData({ loading: true, message: '' }); + try { + const [n, p] = await Promise.all([fetchNotifications(), fetchNotificationPreferences()]); + this.setData({ + items: n.items, + preferences: p.items, + adapterConfigured: p.wechatAdapterConfigured, + }); + } catch (e) { + this.setData({ message: e instanceof Error ? e.message : '消息加载失败' }); + } finally { + this.setData({ loading: false }); + } + }, + async read(e: WechatMiniprogram.BaseEvent) { + const id = String(e.currentTarget.dataset['id']); + await readNotification(id); + await this.load(); + }, + async toggleWechat(e: WechatMiniprogram.SwitchChange) { + const type = String(e.currentTarget.dataset['type']); + const enabled = e.detail.value; + const next = this.data.preferences.map((p) => + p.type === type ? { ...p, wechatEnabled: enabled } : p, + ); + try { + if (enabled) { + const accepted = await new Promise((resolve) => + wx.showModal({ + title: '订阅通知授权', + content: '开启后仍需在微信授权弹窗中接受对应模板。当前操作会记录您的明确选择。', + confirmText: '确认开启', + success: (r) => resolve(r.confirm), + fail: () => resolve(false), + }), + ); + if (!accepted) { + await this.load(); + return; + } + } + const result = await updateNotificationPreferences({ + items: next.map((p) => ({ + type: p.type, + inAppEnabled: p.inAppEnabled, + wechatEnabled: p.wechatEnabled, + })), + }); + this.setData({ + preferences: result.items, + message: enabled + ? '已记录订阅选择;需微信模板授权与平台配置后才能发送。' + : '已关闭微信订阅通知。', + }); + } catch (err) { + this.setData({ message: err instanceof Error ? err.message : '设置失败' }); + await this.load(); + } + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/notifications/notifications.wxml b/apps/miniprogram/miniprogram/pages/notifications/notifications.wxml new file mode 100644 index 0000000..cc16ad7 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/notifications/notifications.wxml @@ -0,0 +1 @@ +我的消息服务进度与活动报名结果正在加载消息…{{message}}暂无消息通知设置站内消息默认开启微信模板与 AppID/AppSecret 尚未配置;现在可以保留偏好,配置完成后生效。{{item.type}} diff --git a/apps/miniprogram/miniprogram/pages/notifications/notifications.wxss b/apps/miniprogram/miniprogram/pages/notifications/notifications.wxss new file mode 100644 index 0000000..a082c34 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/notifications/notifications.wxss @@ -0,0 +1,38 @@ +@import '../../app.wxss'; +.message-card, +.preference-row { + display: block; + width: 100%; + box-sizing: border-box; + margin-top: 20rpx; + padding: 28rpx; + background: #fff; + border: 2rpx solid #eceef0; + border-radius: 20rpx; + text-align: left; +} +.message-card--unread { + border-left: 8rpx solid #ff6b35; +} +.message-title, +.message-body, +.message-time { + display: block; +} +.message-title { + font-weight: 700; +} +.message-body { + margin-top: 12rpx; + color: #555b63; +} +.message-time { + margin-top: 14rpx; + color: #8b9098; + font-size: 22rpx; +} +.preference-row { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/apps/miniprogram/miniprogram/pages/project-detail/project-detail.json b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.json new file mode 100644 index 0000000..262703e --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "项目详情" } diff --git a/apps/miniprogram/miniprogram/pages/project-detail/project-detail.ts b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.ts new file mode 100644 index 0000000..34ef41f --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.ts @@ -0,0 +1,25 @@ +import type { ProjectDetail } from '@lehuo/contracts'; +import { fetchPublicProject } from '../../services/participation'; +Page({ + data: { loading: true, message: '', project: null as ProjectDetail | null }, + onLoad(options: Record) { + if (options['publicId']) void this.load(options['publicId']); + }, + async load(publicId: string) { + try { + const result = await fetchPublicProject(publicId); + this.setData({ project: result.project }); + } catch { + this.setData({ message: '项目暂时无法查看,请稍后重试' }); + } finally { + this.setData({ loading: false }); + } + }, + activities() { + const project = this.data.project; + if (project) + void wx.navigateTo({ + url: `/pages/activities/activities?projectPublicId=${project.publicId}`, + }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxml b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxml new file mode 100644 index 0000000..f5e866d --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxml @@ -0,0 +1 @@ +正在加载…{{message}}{{project.statusLabel}}{{project.name}}{{project.summary}}项目周期{{project.startDate}} 至 {{project.endDate}} · 负责人 {{project.managerDisplayName}}项目介绍{{project.content}} diff --git a/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxss b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxss new file mode 100644 index 0000000..e5418df --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/project-detail/project-detail.wxss @@ -0,0 +1 @@ +@import '../../app.wxss'; diff --git a/apps/miniprogram/miniprogram/pages/projects/projects.json b/apps/miniprogram/miniprogram/pages/projects/projects.json new file mode 100644 index 0000000..9eba9c6 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/projects/projects.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "公益项目" } diff --git a/apps/miniprogram/miniprogram/pages/projects/projects.ts b/apps/miniprogram/miniprogram/pages/projects/projects.ts new file mode 100644 index 0000000..b491ee3 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/projects/projects.ts @@ -0,0 +1,27 @@ +import type { ProjectListItem } from '@lehuo/contracts'; +import { MiniProgramApiError } from '../../services/api-client'; +import { fetchPublicProjects } from '../../services/participation'; + +Page({ + data: { loading: true, message: '', items: [] as ProjectListItem[] }, + onLoad() { + void this.load(); + }, + async load() { + this.setData({ loading: true, message: '' }); + try { + const result = await fetchPublicProjects(); + this.setData({ items: result.items }); + } catch (error) { + this.setData({ + message: error instanceof MiniProgramApiError ? error.message : '项目加载失败,请稍后重试', + }); + } finally { + this.setData({ loading: false }); + } + }, + open(event: WechatMiniprogram.BaseEvent) { + const publicId = event.currentTarget.dataset['id'] as string; + void wx.navigateTo({ url: `/pages/project-detail/project-detail?publicId=${publicId}` }); + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/projects/projects.wxml b/apps/miniprogram/miniprogram/pages/projects/projects.wxml new file mode 100644 index 0000000..1fef769 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/projects/projects.wxml @@ -0,0 +1 @@ +公益项目了解正在开展的公益服务正在加载项目…{{message}}暂无公开项目 diff --git a/apps/miniprogram/miniprogram/pages/projects/projects.wxss b/apps/miniprogram/miniprogram/pages/projects/projects.wxss new file mode 100644 index 0000000..8a08799 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/projects/projects.wxss @@ -0,0 +1,10 @@ +@import '../../app.wxss'; +.card-meta { + display: block; + margin-top: 12rpx; + color: #646a73; + font-size: 24rpx; +} +.state-card { + margin-top: 24rpx; +} diff --git a/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.json b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.json new file mode 100644 index 0000000..2889f34 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "志愿者中心" } diff --git a/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.ts b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.ts new file mode 100644 index 0000000..2892ee3 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.ts @@ -0,0 +1,137 @@ +import type { + ActivityRegistrationItem, + VolunteerProfile, + VolunteerSkillCode, + ServiceCategory, +} from '@lehuo/contracts'; +import { MiniProgramApiError } from '../../services/api-client'; +import { + fetchMyRegistrations, + fetchVolunteerProfile, + saveVolunteerProfile, +} from '../../services/participation'; +import { createClientRequestId } from '../../utils/request-id'; + +Page({ + data: { + loading: true, + busy: false, + message: '', + profile: null as VolunteerProfile | null, + registrations: [] as ActivityRegistrationItem[], + skillSelected: {}, + interestSelected: {}, + form: { + realName: '', + mobile: '', + regionCode: '', + availabilityNote: '', + skillCodes: [] as VolunteerSkillCode[], + interestCategories: [] as ServiceCategory[], + privacyConsent: false, + }, + skills: [ + { code: 'ELDER_CARE', label: '老年关怀' }, + { code: 'CHILD_CARE', label: '儿童陪伴' }, + { code: 'DISABILITY_SUPPORT', label: '残障支持' }, + { code: 'EVENT_SUPPORT', label: '活动协助' }, + { code: 'FIRST_AID', label: '急救技能' }, + { code: 'PHOTOGRAPHY', label: '摄影记录' }, + ], + interests: [ + { code: 'OLD_AGE', label: '老年服务' }, + { code: 'WOMEN_CHILDREN', label: '妇女儿童' }, + { code: 'DISABILITY', label: '残障支持' }, + { code: 'DIFFICULT_FAMILY', label: '困难家庭' }, + { code: 'OTHER', label: '其他公益' }, + ], + }, + onLoad() { + void this.load(); + }, + async load() { + this.setData({ loading: true, message: '' }); + try { + const [profileResult, registrations] = await Promise.all([ + fetchVolunteerProfile(), + fetchMyRegistrations(), + ]); + const profile = profileResult.volunteer; + this.setData({ + profile, + registrations: registrations.items, + skillSelected: Object.fromEntries((profile?.skillCodes ?? []).map((code) => [code, true])), + interestSelected: Object.fromEntries( + (profile?.interestCategories ?? []).map((code) => [code, true]), + ), + ...(profile + ? { + form: { + realName: profile.realName, + mobile: profile.mobile, + regionCode: profile.regionCode, + availabilityNote: profile.availabilityNote, + skillCodes: profile.skillCodes, + interestCategories: profile.interestCategories, + privacyConsent: true, + }, + } + : {}), + }); + } catch (error) { + this.setData({ + message: error instanceof MiniProgramApiError ? error.message : '志愿者中心加载失败', + }); + } finally { + this.setData({ loading: false }); + } + }, + field(event: WechatMiniprogram.Input) { + const key = event.currentTarget.dataset['key'] as string; + this.setData({ [`form.${key}`]: event.detail.value }); + }, + skills(event: WechatMiniprogram.CheckboxGroupChange) { + this.setData({ + 'form.skillCodes': event.detail.value, + skillSelected: Object.fromEntries(event.detail.value.map((code) => [code, true])), + }); + }, + interests(event: WechatMiniprogram.CheckboxGroupChange) { + this.setData({ + 'form.interestCategories': event.detail.value, + interestSelected: Object.fromEntries(event.detail.value.map((code) => [code, true])), + }); + }, + consent(event: WechatMiniprogram.CheckboxGroupChange) { + this.setData({ 'form.privacyConsent': event.detail.value.includes('yes') }); + }, + async submit() { + const form = this.data.form; + if (!form.privacyConsent) { + this.setData({ message: '请先阅读并确认志愿者资料使用说明。' }); + return; + } + this.setData({ busy: true, message: '' }); + try { + await saveVolunteerProfile({ + clientRequestId: createClientRequestId(), + ...(this.data.profile ? { expectedRevision: this.data.profile.revision } : {}), + realName: form.realName, + mobile: form.mobile, + regionCode: form.regionCode, + availabilityNote: form.availabilityNote, + skillCodes: form.skillCodes, + interestCategories: form.interestCategories, + privacyConsent: true, + }); + this.setData({ message: '志愿者资料已提交,请等待工作人员审核。' }); + await this.load(); + } catch (error) { + this.setData({ + message: error instanceof MiniProgramApiError ? error.message : '提交失败,请稍后重试', + }); + } finally { + this.setData({ busy: false }); + } + }, +}); diff --git a/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxml b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxml new file mode 100644 index 0000000..84227d0 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxml @@ -0,0 +1 @@ +志愿者中心认证资料、报名进度与服务时长正在加载…认证状态:{{profile.statusLabel}}累计 {{profile.completedActivityCount}} 次活动,{{profile.totalServiceMinutes}} 分钟服务服务技能服务意向{{message}}我的活动报名暂无报名记录{{item.activityTitle}}{{item.statusLabel}} · {{item.checkInStatusLabel}}服务时长:{{item.serviceMinutes === null ? '待核定' : item.serviceMinutes + ' 分钟'}} diff --git a/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxss b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxss new file mode 100644 index 0000000..614e1ec --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/volunteer-center/volunteer-center.wxss @@ -0,0 +1,29 @@ +@import '../../app.wxss'; +.form-card { + padding: 28rpx; + border-radius: 24rpx; + background: #fff; +} +.form-card label, +.field-label { + display: block; + margin-top: 24rpx; + font-weight: 600; +} +.form-card input, +.form-card textarea { + width: 100%; + min-height: 88rpx; + margin-top: 12rpx; + padding: 18rpx; + border: 2rpx solid #d9dce1; + border-radius: 16rpx; + box-sizing: border-box; +} +.check-row { + display: flex !important; + min-height: 72rpx; + align-items: center; + gap: 12rpx; + font-weight: 400 !important; +} diff --git a/apps/miniprogram/miniprogram/services/api-client.ts b/apps/miniprogram/miniprogram/services/api-client.ts new file mode 100644 index 0000000..90f4760 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/api-client.ts @@ -0,0 +1,65 @@ +import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts'; + +import { readSession, removeSession } from './session'; + +const API_BASE_URL = getApp().globalData.apiBaseUrl; + +type ApiRequestOptions = Omit< + WechatMiniprogram.RequestOption, + 'url' | 'success' | 'fail' | 'complete' +>; + +export class MiniProgramApiError extends Error { + constructor( + message: string, + readonly code: string, + readonly requestId: string | null, + ) { + super(message); + } +} + +export function apiRequest(path: string, options: ApiRequestOptions = {}): Promise { + if (!API_BASE_URL) { + return Promise.reject( + new MiniProgramApiError( + '小程序尚未配置服务地址,请联系工作人员', + 'CONFIGURATION_ERROR', + null, + ), + ); + } + return new Promise((resolve, reject) => { + const session = readSession(); + wx.request | ApiErrorResponse>({ + ...options, + url: `${API_BASE_URL}${path}`, + header: { + 'content-type': 'application/json', + ...(session ? { authorization: `Bearer ${session.accessToken}` } : {}), + ...options.header, + }, + success(response) { + const payload = response.data; + if (response.statusCode >= 200 && response.statusCode < 300 && payload.success) { + resolve(payload.data); + return; + } + const error = payload as ApiErrorResponse; + if (response.statusCode === 401) removeSession(); + reject( + new MiniProgramApiError( + response.statusCode === 401 + ? '登录状态已失效,请返回首页重新登录' + : error.error?.message || '服务连接失败,请稍后重试', + error.error?.code || 'UNKNOWN_ERROR', + error.requestId || null, + ), + ); + }, + fail() { + reject(new MiniProgramApiError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null)); + }, + }); + }); +} diff --git a/apps/miniprogram/miniprogram/services/assigned-cases.ts b/apps/miniprogram/miniprogram/services/assigned-cases.ts new file mode 100644 index 0000000..239de35 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/assigned-cases.ts @@ -0,0 +1,36 @@ +import type { + CreateServiceRecordRequest, + PaginatedServiceCasesResponse, + ServiceCaseDetailResponse, + ServiceCaseStatus, + ServiceRecordResponse, + DownloadUrlResponse, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +export function fetchAssignedCases( + status?: ServiceCaseStatus, +): Promise { + return apiRequest( + `/api/v1/service-cases?page=1&pageSize=50${status ? `&status=${encodeURIComponent(status)}` : ''}`, + ); +} + +export function fetchAssignedCase(publicId: string): Promise { + return apiRequest(`/api/v1/service-cases/${encodeURIComponent(publicId)}`); +} + +export function createAssignedCaseRecord( + publicId: string, + input: CreateServiceRecordRequest, +): Promise { + return apiRequest(`/api/v1/service-cases/${encodeURIComponent(publicId)}/records`, { + method: 'POST', + data: input, + }); +} + +export function fetchAssignedFileUrl(publicId: string): Promise { + return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`); +} diff --git a/apps/miniprogram/miniprogram/services/auth.ts b/apps/miniprogram/miniprogram/services/auth.ts new file mode 100644 index 0000000..893a0c7 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/auth.ts @@ -0,0 +1,50 @@ +import type { CurrentUserResponse, WechatLoginResponse } from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; +import { readSession, removeSession, saveSession, type StoredSession } from './session'; + +function requestWechatCode(): Promise { + return new Promise((resolve, reject) => { + wx.login({ + success(result) { + if (result.code) resolve(result.code); + else reject(new Error('微信未返回登录凭证')); + }, + fail() { + reject(new Error('无法连接微信登录,请稍后重试')); + }, + }); + }); +} + +export function restoreSession(): StoredSession | null { + return readSession(); +} + +export async function restoreCurrentSession(): Promise { + const session = readSession(); + if (!session) return null; + if (session.user) return session; + const result = await apiRequest('/api/v1/me'); + const refreshed = { ...session, user: result.user }; + saveSession(refreshed); + return refreshed; +} + +export async function loginWithWechat(): Promise { + const code = await requestWechatCode(); + const result = await apiRequest('/api/v1/auth/wechat/login', { + method: 'POST', + data: { code }, + }); + saveSession({ + accessToken: result.accessToken, + expiresAt: result.expiresAt, + user: result.user, + }); + return result; +} + +export function clearSession(): void { + removeSession(); +} diff --git a/apps/miniprogram/miniprogram/services/content-notifications.ts b/apps/miniprogram/miniprogram/services/content-notifications.ts new file mode 100644 index 0000000..9d04e45 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/content-notifications.ts @@ -0,0 +1,25 @@ +import type { + ContentPostDetailResponse, + NotificationPreferencesResponse, + PaginatedContentPostsResponse, + PaginatedNotificationsResponse, + UpdateNotificationPreferencesRequest, +} from '@lehuo/contracts'; +import { apiRequest } from './api-client'; +export const fetchPublicContent = (type?: string) => + apiRequest( + `/api/v1/content?page=1&pageSize=50${type ? `&type=${encodeURIComponent(type)}` : ''}`, + ); +export const fetchPublicContentDetail = (id: string) => + apiRequest(`/api/v1/content/${id}`); +export const fetchNotifications = () => + apiRequest('/api/v1/me/notifications?page=1&pageSize=50'); +export const readNotification = (id: string) => + apiRequest<{ read: true }>(`/api/v1/me/notifications/${id}/read`, { method: 'POST' }); +export const fetchNotificationPreferences = () => + apiRequest('/api/v1/me/notifications/preferences'); +export const updateNotificationPreferences = (input: UpdateNotificationPreferencesRequest) => + apiRequest('/api/v1/me/notifications/preferences', { + method: 'PUT', + data: input, + }); diff --git a/apps/miniprogram/miniprogram/services/participation.ts b/apps/miniprogram/miniprogram/services/participation.ts new file mode 100644 index 0000000..8ff75f6 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/participation.ts @@ -0,0 +1,36 @@ +import type { + ActivityDetailResponse, + ActivityRegistrationResponse, + ApplyVolunteerRequest, + PaginatedActivitiesResponse, + PaginatedActivityRegistrationsResponse, + PaginatedProjectsResponse, + ProjectDetailResponse, + VolunteerProfileResponse, +} from '@lehuo/contracts'; +import { apiRequest } from './api-client'; +import { createClientRequestId } from '../utils/request-id'; + +export const fetchPublicProjects = () => + apiRequest('/api/v1/public/projects?page=1&pageSize=50'); +export const fetchPublicProject = (publicId: string) => + apiRequest(`/api/v1/public/projects/${publicId}`); +export const fetchPublicActivities = (projectPublicId?: string) => + apiRequest( + `/api/v1/public/activities?page=1&pageSize=50${projectPublicId ? `&projectPublicId=${encodeURIComponent(projectPublicId)}` : ''}`, + ); +export const fetchPublicActivity = (publicId: string) => + apiRequest(`/api/v1/public/activities/${publicId}`); +export const registerActivity = (publicId: string) => + apiRequest(`/api/v1/activities/${publicId}/registrations`, { + method: 'POST', + data: { clientRequestId: createClientRequestId() }, + }); +export const fetchVolunteerProfile = () => + apiRequest('/api/v1/me/volunteer'); +export const saveVolunteerProfile = (input: ApplyVolunteerRequest) => + apiRequest('/api/v1/me/volunteer', { method: 'PUT', data: input }); +export const fetchMyRegistrations = () => + apiRequest( + '/api/v1/me/activity-registrations?page=1&pageSize=50', + ); diff --git a/apps/miniprogram/miniprogram/services/service-applications.ts b/apps/miniprogram/miniprogram/services/service-applications.ts new file mode 100644 index 0000000..102b45a --- /dev/null +++ b/apps/miniprogram/miniprogram/services/service-applications.ts @@ -0,0 +1,29 @@ +import type { + CreateServiceApplicationRequest, + CreateServiceApplicationResponse, + PaginatedServiceApplicationsResponse, + ServiceApplicationDetailResponse, + ServiceCategoriesResponse, +} from '@lehuo/contracts'; + +import { apiRequest } from './api-client'; + +export function fetchServiceCategories(): Promise { + return apiRequest('/api/v1/service-categories'); +} + +export function createServiceApplication( + input: CreateServiceApplicationRequest, +): Promise { + return apiRequest('/api/v1/service-applications', { method: 'POST', data: input }); +} + +export function fetchMyServiceApplications(): Promise { + return apiRequest('/api/v1/service-applications?page=1&pageSize=50'); +} + +export function fetchServiceApplication( + publicId: string, +): Promise { + return apiRequest(`/api/v1/service-applications/${encodeURIComponent(publicId)}`); +} diff --git a/apps/miniprogram/miniprogram/services/session.ts b/apps/miniprogram/miniprogram/services/session.ts new file mode 100644 index 0000000..9f02e68 --- /dev/null +++ b/apps/miniprogram/miniprogram/services/session.ts @@ -0,0 +1,25 @@ +const STORAGE_KEY = 'lehuo_wechat_session'; + +export interface StoredSession { + accessToken: string; + expiresAt: string; + user?: AuthUser; +} + +export function readSession(): StoredSession | null { + const session = wx.getStorageSync(STORAGE_KEY); + if (!session?.accessToken || Date.parse(session.expiresAt) <= Date.now()) { + wx.removeStorageSync(STORAGE_KEY); + return null; + } + return session; +} + +export function saveSession(session: StoredSession): void { + wx.setStorageSync(STORAGE_KEY, session); +} + +export function removeSession(): void { + wx.removeStorageSync(STORAGE_KEY); +} +import type { AuthUser } from '@lehuo/contracts'; diff --git a/apps/miniprogram/miniprogram/sitemap.json b/apps/miniprogram/miniprogram/sitemap.json new file mode 100644 index 0000000..a9de095 --- /dev/null +++ b/apps/miniprogram/miniprogram/sitemap.json @@ -0,0 +1,4 @@ +{ + "desc": "站点索引配置", + "rules": [{ "action": "allow", "page": "*" }] +} diff --git a/apps/miniprogram/miniprogram/types/app.d.ts b/apps/miniprogram/miniprogram/types/app.d.ts new file mode 100644 index 0000000..26f8adf --- /dev/null +++ b/apps/miniprogram/miniprogram/types/app.d.ts @@ -0,0 +1,5 @@ +interface IAppOption { + globalData: { + apiBaseUrl: string; + }; +} diff --git a/apps/miniprogram/miniprogram/utils/date.spec.ts b/apps/miniprogram/miniprogram/utils/date.spec.ts new file mode 100644 index 0000000..2ce6738 --- /dev/null +++ b/apps/miniprogram/miniprogram/utils/date.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import { formatChinaDateTime } from './date'; + +describe('mini program date formatting', () => { + it('formats valid dates and recovers from invalid values', () => { + expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toMatch(/^2026-08-11 \d{2}:\d{2}$/); + expect(formatChinaDateTime('invalid')).toBe('时间待确认'); + }); +}); diff --git a/apps/miniprogram/miniprogram/utils/date.ts b/apps/miniprogram/miniprogram/utils/date.ts new file mode 100644 index 0000000..7681e5e --- /dev/null +++ b/apps/miniprogram/miniprogram/utils/date.ts @@ -0,0 +1,6 @@ +export function formatChinaDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '时间待确认'; + const pad = (number: number) => String(number).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} diff --git a/apps/miniprogram/miniprogram/utils/request-id.spec.ts b/apps/miniprogram/miniprogram/utils/request-id.spec.ts new file mode 100644 index 0000000..c2a6523 --- /dev/null +++ b/apps/miniprogram/miniprogram/utils/request-id.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { createClientRequestId } from './request-id'; + +describe('client request identifiers', () => { + it('creates distinct UUID v4 shaped idempotency identifiers', () => { + const first = createClientRequestId(); + const second = createClientRequestId(); + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(second).not.toBe(first); + }); +}); diff --git a/apps/miniprogram/miniprogram/utils/request-id.ts b/apps/miniprogram/miniprogram/utils/request-id.ts new file mode 100644 index 0000000..4193911 --- /dev/null +++ b/apps/miniprogram/miniprogram/utils/request-id.ts @@ -0,0 +1,7 @@ +export function createClientRequestId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => { + const random = Math.floor(Math.random() * 16); + const value = character === 'x' ? random : (random & 0x3) | 0x8; + return value.toString(16); + }); +} diff --git a/apps/miniprogram/package.json b/apps/miniprogram/package.json new file mode 100644 index 0000000..27c93ca --- /dev/null +++ b/apps/miniprogram/package.json @@ -0,0 +1,18 @@ +{ + "name": "@lehuo/miniprogram", + "version": "0.1.0", + "private": true, + "dependencies": { + "@lehuo/contracts": "workspace:*" + }, + "scripts": { + "build": "tsc --noEmit", + "test:unit": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/wechat-miniprogram": "^3.4.8", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/apps/miniprogram/project.config.json b/apps/miniprogram/project.config.json new file mode 100644 index 0000000..4744080 --- /dev/null +++ b/apps/miniprogram/project.config.json @@ -0,0 +1,13 @@ +{ + "appid": "touristappid", + "compileType": "miniprogram", + "libVersion": "trial", + "miniprogramRoot": "miniprogram/", + "projectname": "lehuo-public-service", + "setting": { + "es6": true, + "minified": true, + "postcss": true, + "useCompilerPlugins": ["typescript"] + } +} diff --git a/apps/miniprogram/project.private.config.json.example b/apps/miniprogram/project.private.config.json.example new file mode 100644 index 0000000..fabd9d8 --- /dev/null +++ b/apps/miniprogram/project.private.config.json.example @@ -0,0 +1,6 @@ +{ + "projectname": "lehuo-public-service-local", + "setting": { + "compileHotReLoad": true + } +} diff --git a/apps/miniprogram/tsconfig.json b/apps/miniprogram/tsconfig.json new file mode 100644 index 0000000..8dba48a --- /dev/null +++ b/apps/miniprogram/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "types": ["wechat-miniprogram", "vitest/globals"] + }, + "include": ["miniprogram/**/*.ts"] +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5befba5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,81 @@ +name: lehuo-local + +services: + mysql: + image: mysql:8.4.8 + restart: unless-stopped + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_USER: ${MYSQL_USER} + TZ: Asia/Shanghai + ports: + - '3306:3306' + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + mysql-test: + image: mysql:8.4.8 + profiles: ['test'] + restart: unless-stopped + environment: + MYSQL_DATABASE: lehuo_test + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_USER: ${MYSQL_USER} + TZ: Asia/Shanghai + ports: + - '3307:3306' + volumes: + - mysql-test-data:/var/lib/mysql + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + redis: + image: redis:7.4-alpine + restart: unless-stopped + command: ['redis-server', '--appendonly', 'yes'] + ports: + - '6379:6379' + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + restart: unless-stopped + command: server /data --console-address ':9001' + environment: + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + ports: + - '9000:9000' + - '9001:9001' + volumes: + - minio-data:/data + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + minio-data: + mysql-data: + mysql-test-data: + redis-data: diff --git a/docs/00_README_FIRST.md b/docs/00_README_FIRST.md new file mode 100644 index 0000000..658df17 --- /dev/null +++ b/docs/00_README_FIRST.md @@ -0,0 +1,130 @@ +# 乐活公益·暖心服务平台 —— Codex 开发文档包 + +> 版本:V1.0 +> 文档用途:作为 Codex / AI 编程智能体的长期开发上下文、项目规则和验收依据。 +> 最高原则:任何代码实现不得偏离本目录文档。发生冲突时,按本文档规定的优先级处理。 + +--- + +## 1. 项目一句话定义 + +“乐活公益·暖心服务平台”不是公益宣传页,而是一套以微信小程序为公众入口、Web 管理后台为工作人员入口、以“需求申请 → 受理评估 → 建档 → 派单 → 服务 → 反馈 → 回访 → 持续关爱”为核心闭环的公益服务数字化平台。 + +主要服务领域: + +1. 关爱老年人、为老服务; +2. 困境妇女慈善帮扶; +3. 困境儿童关爱与帮扶; +4. 残疾人爱心救助与帮扶; +5. 其他困难群体服务; +6. 志愿者组织、公益活动、公益资源和社会参与。 + +--- + +## 2. Codex 每次开始工作前必须执行 + +按顺序阅读: + +1. `00_README_FIRST.md` +2. `01_PROJECT_CONSTITUTION.md` +3. `02_PRODUCT_PRD_V1.md` +4. `03_ARCHITECTURE.md` +5. `04_DATA_MODEL.md` +6. `05_API_AND_WORKFLOW.md` +7. `06_SECURITY_PRIVACY.md` +8. `07_UI_UX.md` +9. `08_ENGINEERING_RULES.md` +10. `09_TEST_ACCEPTANCE.md` +11. `10_TASK_STATE.md` +12. `11_DECISIONS.md` + +如果只是小范围修复,也至少必须阅读: + +- `01_PROJECT_CONSTITUTION.md` +- `10_TASK_STATE.md` +- 与修改模块相关的文档 +- `11_DECISIONS.md` + +不得仅根据聊天中最近一句话修改项目。 + +--- + +## 3. 开发结束后必须回写 + +每完成一个任务,必须: + +1. 更新 `10_TASK_STATE.md`; +2. 若产生架构/技术/业务决策,更新 `11_DECISIONS.md`; +3. 若接口变化,更新 API 文档; +4. 若表结构变化,更新数据模型与 migration; +5. 增加或更新自动化测试; +6. 执行 lint、typecheck、unit test、integration test、build; +7. 输出本次变更摘要、影响范围、风险、回滚方式。 + +禁止“只改代码、不改文档”。 + +--- + +## 4. 冲突处理优先级 + +从高到低: + +1. 数据安全、隐私、法律合规; +2. `01_PROJECT_CONSTITUTION.md` 中的硬约束; +3. 已确认的产品业务规则; +4. 数据一致性和可审计性; +5. API 契约; +6. UI/UX 规范; +7. 工程偏好; +8. Codex 自己认为“更先进”的方案。 + +如果 Codex 认为有更好的方案,但会改变既有边界,必须记录到 `11_DECISIONS.md` 的“待确认建议”,不能擅自实施。 + +--- + +## 5. 代码仓库建议结构 + +```text +lehuo-public-service/ +├─ apps/ +│ ├─ miniprogram/ # 微信小程序 +│ ├─ admin-web/ # Web 管理后台 +│ └─ api/ # 后端 API +├─ packages/ +│ ├─ contracts/ # DTO / enum / schema / API 契约 +│ ├─ shared/ # 无业务副作用的共享代码 +│ ├─ eslint-config/ +│ └─ tsconfig/ +├─ infra/ +│ ├─ docker/ +│ ├─ nginx/ +│ ├─ scripts/ +│ └─ backup/ +├─ docs/ +│ ├─ 本文档包 +│ └─ diagrams/ +├─ migrations/ +├─ .env.example +├─ pnpm-workspace.yaml +├─ package.json +├─ AGENTS.md +└─ README.md +``` + +--- + +## 6. AGENTS.md 必须包含 + +仓库根目录 `AGENTS.md` 应复制以下核心规则: + +- 先读 `docs/00_README_FIRST.md` 与 `docs/01_PROJECT_CONSTITUTION.md`。 +- 不擅自改变产品定位、技术栈、数据库类型、认证方式。 +- 不删除既有功能来“简化实现”。 +- 不通过 mock 假装功能完成。 +- 不将敏感信息写入日志。 +- 每个业务状态变化必须可审计。 +- 所有表结构变化必须 migration。 +- 所有 API 变更必须同步 contracts。 +- 完成任务必须更新 `docs/10_TASK_STATE.md`。 +- 任何架构决策必须更新 `docs/11_DECISIONS.md`。 +- 不因“AI认为更合理”而推翻已经批准的业务规则。 diff --git a/docs/01_PROJECT_CONSTITUTION.md b/docs/01_PROJECT_CONSTITUTION.md new file mode 100644 index 0000000..9e66507 --- /dev/null +++ b/docs/01_PROJECT_CONSTITUTION.md @@ -0,0 +1,157 @@ +# 01 项目开发宪法(不可随意修改) + +## 1. 产品定位硬约束 + +本项目必须始终是: + +> 公益服务数字化平台,而不是宣传网站、商城、社交平台或复杂互联网平台。 + +V1.0 第一目标:让真实服务闭环跑通。 + +主链路: + +```text +服务对象/代申请人提交需求 +→ 工作人员受理 +→ 需求评估 +→ 建立/关联服务对象档案 +→ 创建服务工单 +→ 分配工作人员/志愿者 +→ 执行服务 +→ 留痕 +→ 服务对象确认/反馈 +→ 回访 +→ 必要时继续跟进 +``` + +任何新功能都必须回答:“是否增强这条主链路?” + +--- + +## 2. 用户体验硬约束 + +1. 小程序优先服务普通群众、老人和低数字技能用户。 +2. 主要操作不得依赖复杂手势。 +3. 核心按钮大、清楚、可理解。 +4. 服务申请尽量在 3~4 个步骤内完成。 +5. 表单遵循“少填、可选、可拍、可说、可代办”。 +6. 不在前端暴露技术术语、内部状态码。 +7. 不为了视觉炫技牺牲加载速度和可理解性。 +8. 所有失败状态必须给普通人能看懂的说明与下一步动作。 +9. 适老化不是“放大字体”这么简单,必须包含点击区域、对比度、信息密度、步骤数量、错误恢复能力。 +10. 功能完成度优先于动画和装饰。 + +--- + +## 3. 技术硬约束 + +1. 国内网络环境可稳定开发、构建、部署和访问。 +2. 微信小程序作为公众端。 +3. Web 管理后台作为机构工作人员端。 +4. 后端采用模块化单体优先,V1.0 禁止为了“先进”拆微服务。 +5. MySQL 为核心关系数据库。 +6. Redis 只用于缓存、限流、短期状态等可恢复数据,不作为业务事实唯一存储。 +7. 文件不保存到应用容器本地磁盘;生产环境使用对象存储。 +8. 必须 Docker 化。 +9. 环境分 dev / test / staging / prod。 +10. 配置必须环境变量化,禁止把密钥提交 Git。 +11. 所有数据库修改必须 migration。 +12. 生产部署必须有备份、恢复、日志、监控和健康检查。 +13. 对外部供应商能力必须封装 Adapter,不让业务层直接绑定云厂商 SDK。 +14. V1.0 不引入 Kubernetes。 +15. V1.0 不引入复杂事件总线,必要异步任务先使用可靠 Job/Queue 模式。 + +--- + +## 4. 安全硬约束 + +平台会处理老人、困境妇女、儿童、残疾人、困难家庭等敏感服务信息。 + +必须做到: + +- 默认最小权限; +- 后台 RBAC; +- 资源级权限校验; +- 敏感字段脱敏; +- 操作审计; +- 不允许越权查询; +- 不允许通过 ID 枚举他人服务记录; +- 数据导出必须受控; +- 文件下载必须鉴权; +- 上传文件做类型、大小、扩展名、MIME 校验; +- Token、手机号、身份证件、地址、儿童信息不得写入普通日志; +- 删除业务数据优先软删除/归档,禁止随意物理删除; +- 关键状态变更保留历史; +- 不满 14 周岁相关信息按监护人授权与更严格权限原则处理; +- 不把真实生产数据复制到开发环境; +- 测试数据必须脱敏或生成。 + +--- + +## 5. 可靠性硬约束 + +1. 所有写请求应支持幂等或防重复提交。 +2. 重要状态迁移必须使用数据库事务。 +3. 不允许“接口成功但数据库只写了一半”。 +4. 异步任务失败必须可重试、可查询。 +5. 外部服务失败不能拖垮主业务。 +6. 外部短信、订阅消息、对象存储失败应有降级和补偿。 +7. 服务工单状态必须经过状态机校验,不能任意改字符串。 +8. 每个关键实体保留 `created_at / updated_at / created_by / updated_by`。 +9. 每个关键业务操作有 audit log。 +10. 任何“已完成”都必须有可验证结果,不能只靠前端显示。 + +--- + +## 6. 易维护硬约束 + +- 避免自研基础组件; +- 避免魔法数字; +- 枚举集中管理; +- DTO 与数据库 Entity 分离; +- Controller 不写复杂业务; +- Service 不直接散落第三方 SDK; +- Repository 层负责数据访问; +- 公共错误码统一; +- 日志统一; +- 配置集中; +- 每个模块有 README 或模块说明; +- 复杂逻辑写测试; +- 删除代码前检查是否有业务依赖; +- 依赖升级必须经过测试,不追新版本。 + +--- + +## 7. V1.0 禁止范围 + +除非后续明确批准,V1.0 禁止: + +- 在线公开募捐核心交易系统; +- 复杂社交/朋友圈; +- 即时 IM 聊天系统自研; +- AI 自动作出救助资格决定; +- 人脸识别; +- 未经必要性论证收集身份证完整信息; +- 微服务/K8s; +- 大数据平台; +- 区块链; +- 自研工作流引擎; +- 复杂积分商城; +- 过度游戏化; +- 对外开放 API 平台; +- 多租户 SaaS 架构。 + +--- + +## 8. AI 功能原则 + +AI 是效率工具,不是 V1.0 主角。 + +后续 AI 必须: + +- 默认建议、不自动做高风险决定; +- AI 结果明确标记; +- 工作人员确认后才写入正式业务记录; +- 不把敏感数据发送到未经批准的外部模型; +- 能关闭; +- 业务流程不依赖 AI 才能运行。 diff --git a/docs/02_PRODUCT_PRD_V1.md b/docs/02_PRODUCT_PRD_V1.md new file mode 100644 index 0000000..61538e5 --- /dev/null +++ b/docs/02_PRODUCT_PRD_V1.md @@ -0,0 +1,454 @@ +# 02 V1.0 产品需求文档(PRD) + +## 1. V1.0 目标 + +交付可真实投入公益服务的第一版: + +- 群众能找到服务、提交申请、看进度、给反馈; +- 家属/社区/志愿者能代申请; +- 工作人员能受理、建档、派单、留痕、回访; +- 志愿者能注册、报名活动; +- 管理员能管理项目、内容、人员、权限和统计。 + +--- + +## 2. 用户角色 + +### 2.1 PUBLIC + +普通公众。浏览公益项目、活动、组织介绍、参与入口。 + +### 2.2 APPLICANT + +申请人。可以是服务对象本人,也可以是代申请人。 + +### 2.3 BENEFICIARY + +服务对象。老人、困境妇女、困境儿童、残疾人及其他困难群体。 + +### 2.4 VOLUNTEER + +志愿者。 + +### 2.5 STAFF + +公益工作人员。 + +### 2.6 PROJECT_MANAGER + +公益项目负责人。 + +### 2.7 ADMIN + +系统管理员。 + +### 2.8 SUPER_ADMIN + +仅限极少数技术/管理人员,用于系统级配置,日常不使用。 + +--- + +## 3. 小程序信息架构 + +底部 Tab 建议: + +1. 首页 +2. 服务 +3. 公益 +4. 我的 + +### 首页 + +- Logo/品牌语 +- 我需要帮助 +- 帮他人申请 +- 为老服务 +- 妇女帮扶 +- 儿童关爱 +- 残疾人帮扶 +- 最新公益项目 +- 最新活动 +- 我要做公益 + +### 服务 + +- 服务分类 +- 服务申请 +- 我的申请 +- 服务进度 +- 我的反馈 +- 联系乐活 + +### 公益 + +- 公益项目 +- 公益动态 +- 公益活动 +- 志愿者招募 +- 企业/资源合作 +- 乐活公益介绍 + +### 我的 + +根据身份动态显示: + +- 微信登录/绑定手机号 +- 我的资料 +- 我的服务申请 +- 我的活动 +- 志愿者资料 +- 我的志愿服务 +- 消息 +- 隐私设置 +- 联系客服 + +--- + +## 4. 服务申请 + +### 4.1 申请类型 + +- OLD_AGE:老年关爱/为老服务 +- WOMEN:困境妇女 +- CHILD:困境儿童 +- DISABILITY:残疾人帮扶 +- LIFE_DIFFICULTY:生活困难 +- OTHER:其他 + +### 4.2 申请身份 + +- SELF:本人申请 +- FAMILY:家属代申请 +- COMMUNITY:社区/机构代申请 +- VOLUNTEER:志愿者代申请 +- OTHER:其他 + +### 4.3 最小字段 + +- 服务类别 +- 申请人与服务对象关系 +- 服务对象姓名或称呼 +- 联系方式 +- 所在区域(不默认要求精确门牌) +- 需求描述 +- 隐私授权确认 + +### 4.4 可选字段 + +- 图片/附件 +- 期望联系时间 +- 是否行动不便 +- 是否需要上门 +- 紧急程度(用户只能表达,最终等级由工作人员评估) +- 语音描述(二期可增强) + +### 4.5 提交 + +提交后: + +- 生成不可预测的业务编号; +- 显示“已收到”; +- 明确说明后续由工作人员联系; +- 不承诺超出机构能力的即时救援; +- 若用户选择明显紧急风险,应提示联系 110/120 等适当公共紧急服务,但具体文案由运营审核。 + +--- + +## 5. 服务申请状态机 + +```text +SUBMITTED + ↓ +PENDING_CONTACT + ↓ +ASSESSING + ├─→ REJECTED/OUT_OF_SCOPE + ├─→ REFERRED + ↓ +ACCEPTED + ↓ +SERVICE_PLANNED + ↓ +IN_SERVICE + ↓ +SERVICE_COMPLETED + ↓ +FOLLOW_UP_PENDING + ↓ +CLOSED +``` + +可增加: + +- CANCELLED +- SUSPENDED + +规则: + +- 状态变化必须记录历史; +- 不可跳过关键节点,除管理员执行带原因的纠正操作; +- 关闭后再次产生需求,应创建新申请/跟进记录,而不是覆盖旧记录。 + +--- + +## 6. 服务对象档案 + +每个 Beneficiary 一人一档。 + +核心字段: + +- 基础身份 +- 联系信息 +- 联系人/监护人 +- 所属服务类别 +- 地址分级信息 +- 风险/关爱标签 +- 备注 +- 是否重点关爱 +- 档案状态 + +严禁把所有信息塞入一个 JSON 字段。 + +儿童、残疾、健康、家庭困难等敏感字段应按实际必要性分表/分域并控制权限。 + +--- + +## 7. 工单 + +服务申请被接受后创建 Service Case / Work Order。 + +字段: + +- 工单编号 +- beneficiary_id +- application_id +- project_id(可空) +- 服务类别 +- 负责人 +- 计划日期 +- 服务地点 +- 服务说明 +- 当前状态 +- 优先级 +- 完成时间 +- 结果摘要 +- 后续计划 + +工单可关联多个执行人/志愿者,但只有一个责任负责人。 + +--- + +## 8. 服务记录 + +一次工单可有多次服务记录。 + +记录: + +- 服务时间 +- 服务类型 +- 服务人员 +- 服务内容 +- 服务结果 +- 图片附件 +- 服务对象确认情况 +- 后续建议 +- 创建人 + +服务记录创建后若修改,必须保留修订痕迹或审计日志。 + +--- + +## 9. 回访 + +回访任务来源: + +- 服务完成自动创建; +- 重点关爱对象周期任务; +- 工作人员手工创建。 + +回访结果: + +- SATISFIED +- GENERALLY_SATISFIED +- UNSATISFIED +- NEED_MORE_HELP +- UNREACHABLE +- OTHER + +若 `NEED_MORE_HELP`: + +- 必须支持一键创建后续申请/工单; +- 不能只保存文字然后无人处理。 + +--- + +## 10. 志愿者 + +注册字段遵循最小必要: + +- 姓名 +- 手机 +- 所在区域 +- 可服务时间 +- 技能标签 +- 感兴趣领域 +- 紧急联系人(仅确有管理需要时) +- 志愿者状态 + +状态: + +- APPLIED +- APPROVED +- REJECTED +- SUSPENDED +- INACTIVE + +志愿者默认不能查看完整服务对象档案。 + +--- + +## 11. 公益活动 + +活动: + +- 标题 +- 封面 +- 项目 +- 时间 +- 地点 +- 招募人数 +- 报名截止时间 +- 说明 +- 状态 + +状态: + +- DRAFT +- PUBLISHED +- REGISTRATION_CLOSED +- IN_PROGRESS +- COMPLETED +- CANCELLED + +报名: + +- 用户 +- 志愿者身份 +- 报名时间 +- 审核状态 +- 签到状态 +- 服务时长 + +--- + +## 12. 公益项目 + +项目用于: + +- 展示; +- 归集服务工单; +- 统计服务成果; +- 后续政府购买服务项目管理。 + +V1 字段: + +- 项目名称 +- 项目类别 +- 简介 +- 详细介绍 +- 封面 +- 项目周期 +- 状态 +- 是否公开 +- 负责人 +- 目标人数(可空) +- 排序 + +--- + +## 13. 内容管理 + +内容类型: + +- 机构介绍 +- 公益动态 +- 服务故事 +- 通知公告 +- 常见问题 + +内容必须支持草稿和发布,不允许编辑即立即对外。 + +涉及服务对象故事: + +- 必须有内容审核流程; +- 展示前脱敏; +- 未授权不得公开身份性信息。 + +--- + +## 14. 消息通知 + +V1: + +- 站内消息 +- 微信订阅消息(按微信平台能力和用户授权使用) + +事件: + +- 申请提交 +- 申请状态变化 +- 服务安排 +- 服务完成 +- 待评价/回访 +- 活动报名结果 + +通知失败不能回滚主交易。 + +--- + +## 15. Web 后台 + +一级菜单: + +- 工作台 +- 服务申请 +- 服务工单 +- 服务对象 +- 回访管理 +- 公益项目 +- 公益活动 +- 志愿者 +- 内容管理 +- 数据统计 +- 用户与权限 +- 系统设置 +- 审计日志(高权限) + +工作台至少展示: + +- 新申请 +- 待联系 +- 评估中 +- 服务中 +- 待回访 +- 今日服务 +- 超期任务 +- 重点关爱提醒 + +--- + +## 16. 数据统计 V1 + +统计以真实业务数据计算: + +- 申请数 +- 受理数 +- 完成服务数 +- 服务人次 +- 去重服务对象数 +- 各服务类别数量 +- 志愿者数量 +- 活动数量 +- 志愿服务时长 + +不得用手工填写数字覆盖真实统计结果。 diff --git a/docs/03_ARCHITECTURE.md b/docs/03_ARCHITECTURE.md new file mode 100644 index 0000000..8a3ddd4 --- /dev/null +++ b/docs/03_ARCHITECTURE.md @@ -0,0 +1,279 @@ +# 03 技术架构 + +## 1. 总体选择 + +优先采用“TypeScript 全栈 + 模块化单体 + MySQL + Redis + 对象存储 + Docker”。 + +推荐: + +### 微信小程序 + +- 微信小程序原生框架 +- TypeScript +- TDesign Miniprogram(优先复用成熟组件) +- 自建 API Client +- 不使用重量级跨端框架作为 V1 默认方案 + +### 管理后台 + +- Vue 3 +- TypeScript +- Vite +- Pinia +- Vue Router +- TDesign Vue Next 或成熟国内 UI 组件库 +- ECharts(仅统计图表需要时) + +### API + +- Node.js LTS +- NestJS +- TypeScript +- REST API +- Prisma 或 TypeORM 二选一;建议 Prisma,但一旦项目启动后不得混用 +- OpenAPI/Swagger +- Zod/class-validator 统一选一种 DTO 校验体系,NestJS 默认可使用 class-validator + +### 数据 + +- MySQL 8.x +- Redis 7.x +- 对象存储:腾讯云 COS 为生产默认实现 +- 本地开发:MinIO 兼容 S3 API + +### 部署 + +- Docker +- Docker Compose +- Nginx +- 腾讯云 CVM / 轻量云服务器可作为早期部署载体 +- 数据库生产优先托管 MySQL;预算有限时也可独立数据库容器,但必须有可靠备份 +- HTTPS +- 国内域名和备案按实际上线要求办理 + +--- + +## 2. 为什么不做微服务 + +当前机构规模和 V1 业务复杂度不需要微服务。 + +模块化单体优点: + +- 部署简单; +- 成本低; +- 调试简单; +- 事务一致性容易保证; +- Codex 更容易保持全局一致; +- 后续真有规模需求时可按模块拆分。 + +模块边界仍必须清晰。 + +--- + +## 3. API 模块建议 + +```text +auth +users +profiles +beneficiaries +service-applications +service-cases +service-records +follow-ups +projects +activities +volunteers +content +files +notifications +statistics +audit +permissions +system +``` + +每模块: + +- controller +- service +- repository +- dto +- entities/model +- policy/permission +- tests + +--- + +## 4. 分层规则 + +```text +Controller + ↓ +Application Service + ↓ +Domain Rules / Policy + ↓ +Repository / Adapter + ↓ +Database / External Service +``` + +禁止: + +- Controller 直接访问 ORM; +- 页面直接写 SQL; +- 业务层直接调用 COS/微信 SDK; +- 前端自己判断权限代替后端校验。 + +--- + +## 5. 外部 Adapter + +必须封装: + +- WeChatAuthAdapter +- WeChatSubscribeMessageAdapter +- ObjectStorageAdapter +- SmsAdapter(若以后使用) +- AIAdapter(二期) +- GeocodingAdapter(二期) + +这样可更换供应商。 + +--- + +## 6. API 规范 + +统一前缀: + +`/api/v1` + +响应: + +```json +{ + "success": true, + "data": {}, + "requestId": "..." +} +``` + +错误: + +```json +{ + "success": false, + "error": { + "code": "APPLICATION_NOT_FOUND", + "message": "未找到该服务申请" + }, + "requestId": "..." +} +``` + +HTTP 状态码必须正确使用,不得永远 200。 + +--- + +## 7. 认证 + +### 小程序端 + +- wx.login 获取 code; +- 后端换取微信身份; +- 服务端创建自己的 session/token; +- token 轮换/过期; +- 手机号绑定按微信开放能力和实际授权流程实现; +- openid/unionid 绝不作为前端授权依据。 + +### 后台 + +推荐: + +- 用户名/手机号 + 强密码; +- 生产可增加 MFA; +- HttpOnly Secure Cookie 优先; +- 管理后台不把长期 Token 放 localStorage。 + +--- + +## 8. 权限 + +RBAC + 资源级 Policy。 + +示例: + +- STAFF 可以看自己负责的工单; +- PROJECT_MANAGER 可以看自己项目范围; +- ADMIN 可跨项目; +- VOLUNTEER 只能查看分配给自己的最小必要任务信息; +- 普通小程序用户只能查看自己提交或合法关联的申请。 + +--- + +## 9. 状态机 + +服务申请、工单、活动、志愿者审批必须采用明确状态机。 + +所有 transition: + +- 验证当前状态; +- 验证目标状态; +- 验证权限; +- 事务写入; +- 写 history; +- 写 audit; +- 事务提交后再触发通知。 + +--- + +## 10. 幂等 + +以下必须防重复: + +- 服务申请提交 +- 活动报名 +- 状态变更 +- 服务记录提交 +- 回访提交 +- 文件关联 +- 微信回调(若未来有) + +方案: + +- clientRequestId / idempotencyKey +- 数据库唯一索引 +- 事务 +- 必要时 Redis 短锁 + +--- + +## 11. 可观测性 + +必须: + +- requestId +- structured logging +- error tracking +- health endpoint +- readiness/liveness +- 慢接口日志 +- 数据库慢查询 +- 关键任务状态 + +日志中禁止敏感数据。 + +--- + +## 12. 备份 + +至少: + +- MySQL 每日自动备份; +- 保留 7/30 天策略可配置; +- 对象存储版本/生命周期按预算配置; +- 每季度至少做一次恢复演练; +- 恢复步骤写入 `infra/backup/README.md`。 + +备份成功≠可恢复,必须验证恢复。 diff --git a/docs/04_DATA_MODEL.md b/docs/04_DATA_MODEL.md new file mode 100644 index 0000000..3a36dd1 --- /dev/null +++ b/docs/04_DATA_MODEL.md @@ -0,0 +1,472 @@ +# 04 数据模型 + +## 1. 通用字段规范 + +核心表统一包含: + +- id: bigint/uuid(二选一后固定;建议 bigint 内部主键 + public_id 随机ID) +- public_id: varchar,外部暴露,禁止连续可枚举 +- created_at +- updated_at +- created_by(后台业务表适用) +- updated_by +- deleted_at(需要软删除的表) + +时间统一数据库 UTC 存储,展示按 Asia/Shanghai。 + +--- + +## 2. 核心实体 + +### users + +微信/后台统一用户主体,避免重复身份系统。 + +- id +- public_id +- status +- user_type +- mobile(加密/脱敏策略) +- nickname +- avatar_url +- last_login_at + +### wechat_identities + +- user_id +- openid_ciphertext(AES-256-GCM) +- openid_hash(HMAC-SHA-256,仅用于等值查询) +- unionid_ciphertext nullable +- unionid_hash nullable +- appid +- created_at + 唯一索引 `(appid, openid_hash)`;日志和 API 均不得返回 openid/unionid。 + +### staff_profiles + +- user_id +- employee_no nullable +- display_name +- department +- status + +工作人员显示名不是服务对象敏感档案字段;严禁把其扩展为手机号、证件号或详细住址。服务对象称呼仍按下文要求加密。 + +### roles / permissions / user_roles / role_permissions + +标准 RBAC。Phase 1 预置 `VOLUNTEER`、`STAFF`、`PROJECT_MANAGER`、`ADMIN`、 +`SUPER_ADMIN`,权限码由 shared contracts 统一定义。角色替换必须在事务中写审计,并禁止移除最后一名有效超级管理员。 + +### admin_credentials + +- user_id(唯一) +- username(唯一) +- password_hash(Argon2id) +- failed_login_count +- locked_until nullable +- password_changed_at + +### auth_sessions + +- public_id(随机 UUID) +- user_id +- channel(WECHAT/ADMIN) +- token_hash(仅保存带 pepper 的 HMAC,不保存明文 token) +- expires_at +- last_used_at nullable +- revoked_at nullable + +同一用户、同一渠道重新登录时撤销旧会话;过期、撤销或非 ACTIVE 用户的会话均无效。 + +--- + +## 3. beneficiary_profiles + +服务对象档案: + +- id +- public_id +- display_name_encrypted / display_name_hash +- gender nullable +- birth_year nullable +- mobile_encrypted / mobile_hash nullable +- region_code +- address_detail_encrypted nullable +- primary_category +- key_care_flag +- is_minor +- status +- notes_encrypted nullable +- created_by +- timestamps + +不要默认收集完整身份证。 + +--- + +## 4. beneficiary_contacts + +- beneficiary_id +- public_id +- name_encrypted +- relation +- mobile_encrypted / mobile_hash +- is_guardian +- is_emergency_contact +- consent_record_id nullable + +--- + +## 5. beneficiary_tags / tags + +标签: + +- 独居 +- 高龄 +- 行动不便 +- 定期探访 +- 困境儿童 + 等。 + +标签只能帮助服务,不应成为歧视性画像。 + +--- + +## 6. service_applications + +- id +- public_id(随机 UUID,仅此字段对外暴露) +- application_no(随机业务编号) +- applicant_user_id +- beneficiary_id nullable +- consent_id +- source_follow_up_task_id nullable(继续帮扶来源,唯一) +- parent_application_id nullable(原申请) +- client_request_id +- request_fingerprint(域隔离 HMAC-SHA-256) +- apply_mode +- service_category +- beneficiary_name_encrypted(AES-256-GCM) +- contact_mobile_encrypted(AES-256-GCM) +- contact_mobile_hash(域隔离 HMAC-SHA-256,仅用于等值查询) +- region_code +- description_encrypted(AES-256-GCM) +- preferred_contact_time nullable +- needs_home_visit nullable +- mobility_difficulty nullable +- status +- source +- submitted_at +- accepted_at nullable +- closed_at nullable +- timestamps + +首次申请在 Phase 2 创建时不自动建立 beneficiary 档案;Phase 3 经人工核验后关联。Phase 4 的继续帮扶申请保留 `parent_application_id` 和 `source_follow_up_task_id`,并创建新的独立 consent,不修改或复用原申请授权。 + +索引: + +- application_no unique +- `(applicant_user_id, client_request_id)` unique,保证数据库级幂等 +- applicant_user_id + submitted_at +- status + submitted_at +- service_category + submitted_at +- contact_mobile_hash + +--- + +## 7. service_application_status_history + +- application_id +- from_status +- to_status +- operator_user_id +- reason_code +- created_at + +不可更新,只追加。 + +--- + +## 8. service_cases + +- id +- public_id +- case_no +- application_id +- accept_request_id / accept_request_fingerprint(接单幂等) +- beneficiary_id +- owner_staff_id +- category +- priority +- status +- planned_start_at nullable +- planned_end_at nullable +- service_location_encrypted nullable +- summary_encrypted +- result_summary_encrypted nullable +- follow_up_plan_encrypted nullable +- completed_at nullable +- complete_request_id / complete_request_fingerprint nullable(完成服务幂等) +- timestamps + +--- + +## 9. case_assignees + +支持多人协作: + +- case_id +- user_id +- assignee_type STAFF/VOLUNTEER +- role +- assigned_at +- removed_at nullable + +--- + +## 10. service_records + +- id +- public_id +- case_id +- beneficiary_id +- service_start_at +- service_end_at nullable +- service_type +- client_request_id / request_fingerprint(同一创建者范围内幂等) +- content_encrypted +- result_encrypted +- follow_up_suggestion_encrypted nullable +- beneficiary_confirm_status nullable +- created_by +- timestamps + +`service_record_revisions` 在修改前保存加密快照、原 revision 和修改人;更新使用 `expectedRevision` 乐观锁,防止覆盖并支持审计追溯。 + +--- + +## 11. follow_up_tasks + +- id +- public_id / follow_up_no(均不可预测) +- beneficiary_id +- case_id nullable +- owner_staff_id +- due_at +- follow_up_type(SERVICE_COMPLETION / KEY_CARE / MANUAL) +- status(PENDING / COMPLETED / CANCELLED) +- source(AUTO_SERVICE_COMPLETION / STAFF_MANUAL / KEY_CARE_PLAN) +- reason_encrypted +- result_code nullable +- result_note_encrypted nullable +- create_request_id / create_request_fingerprint +- complete_request_id / complete_request_fingerprint nullable +- completed_at nullable +- created_by / updated_by / timestamps + +自动回访与服务完成在同一事务创建。人工回访以 `(created_by, create_request_id)` 唯一;完成请求保存指纹并安全重放。 + +## 12. follow_up_status_history + +- follow_up_task_id +- from_status nullable +- to_status +- operator_user_id +- reason_code +- created_at + +只追加,不更新。继续帮扶申请通过 `service_applications.source_follow_up_task_id` 回指来源,原申请历史不覆盖。 + +--- + +## 13. volunteers / volunteer_skills + +volunteers: + +- user_id +- real_name +- mobile +- region_code +- availability_note +- status +- approved_by nullable +- approved_at nullable + +skills: + +- volunteer_id +- skill_code + +--- + +## 14. projects + +- id +- public_id +- project_no +- client_request_id / request_fingerprint +- name +- category +- summary +- content +- cover_file_id +- manager_user_id +- start_date +- end_date +- target_people nullable +- status +- is_public +- sort_order +- timestamps + +`(created_by_id, client_request_id)` 唯一;`project_status_history` 只追加记录创建、发布和完成。`service_cases.project_id` 可空,用于把既有工单归集到项目但不改变工单事实。 + +--- + +## 15. activities + +- id +- public_id +- activity_no +- client_request_id / request_fingerprint +- project_id nullable +- title +- summary +- content +- cover_file_id +- start_at +- end_at +- registration_deadline +- location_text +- capacity nullable +- status +- published_at nullable +- timestamps + +`(created_by_id, client_request_id)` 唯一;`activity_status_history` 只追加记录活动状态变化。 + +--- + +## 16. activity_registrations + +- activity_id +- user_id +- volunteer_id +- public_id / client_request_id / request_fingerprint +- status +- check_in_status +- registered_at +- reviewed_by / reviewed_at / review_note_encrypted nullable +- checked_in_at nullable +- checked_in_by nullable +- service_minutes nullable +- service_minutes_recorded_at / service_minutes_recorded_by nullable + +唯一 `(activity_id,user_id)` 防重复报名。 +`activity_registration_history` 只追加保存报名审核、取消、签到和服务时长变更前后事实,并以全局请求 UUID 唯一约束支持安全重放。 + +志愿者资料的姓名、手机号、可参与时间和审核备注加密;手机号与姓名仅保存域隔离 HMAC 查询值。`volunteer_status_history` 只追加,并与志愿者角色授予/撤销事务一致。 + +--- + +## 17. content_posts + +- id +- public_id +- type +- title +- summary +- content +- cover_file_id nullable +- status DRAFT/REVIEW/PUBLISHED/ARCHIVED +- author_user_id +- reviewer_user_id nullable +- published_at nullable +- timestamps + +--- + +## 18. files + +只保存元数据: + +- id +- public_id +- storage_provider +- bucket +- object_key +- original_filename_encrypted +- mime_type +- size +- sha256 +- business_type +- uploader_user_id +- status +- created_at + +`service_record_files` 关联服务记录与已确认文件。对象 key 使用随机值,上传确认时复核大小、MIME 和可选 SHA-256;不匹配文件进入隔离状态。 + +Phase 5 允许 `files.case_id` 为空,并通过 `business_type=PROJECT_COVER/ACTIVITY_COVER` 关联公开封面;封面仍经同一私有对象存储 Adapter 生成短时读取地址,不保存永久公网 URL。 + +前端不得直接拼接永久公网 URL。 + +--- + +## 19. consents + +- id +- public_id +- user_id nullable +- beneficiary_id nullable +- consent_type +- version +- granted_by +- granted_at +- revoked_at nullable +- evidence_json(仅保存必要证明) +- ip_hash nullable +- created_at + +Phase 2 每次申请显式要求 `privacyConsent=true`,记录隐私同意版本、授权主体、授权时间、最小证据和 IP HMAC;不得在证据中重复保存申请敏感明文。 + +--- + +## 20. audit_logs + +只追加: + +- id +- request_id +- actor_user_id +- actor_role +- action +- resource_type +- resource_public_id +- result +- metadata_json(不得含敏感明文) +- ip_hash +- user_agent_summary +- created_at + +--- + +## 21. notifications / notification_deliveries + +通知实体与发送记录分开,支持重试和追踪。 + +Phase 6 已落地 `content_posts`、只追加的 `content_post_status_history`、`notifications`、`notification_preferences` 和 `notification_deliveries`。消息以 `(user_id,event_key)` 唯一防重,微信投递以 `(notification_id,channel)` 唯一,失败最多重试五次后进入 `DEAD`。 + +Phase 7 不建立可人工修改的统计快照;九项 V1 指标直接从申请、工单、服务记录、服务对象、志愿者、活动和报名服务分钟事实计算。 + +--- + +## 22. 数据库规则 + +1. 必须使用 FK 或由 ORM/服务保证等价完整性;禁止无约束“凭感觉关联”。 +2. 唯一业务编号必须 unique。 +3. 常查字段建组合索引。 +4. 禁止对大文本字段滥建索引。 +5. 金额如未来出现用 decimal,禁止 float。 +6. 所有枚举在 contracts 中有单一事实来源。 +7. migration 不允许 destructive change 直接上线。 +8. 删除列采用“停用 → 迁移 → 验证 → 后续版本删除”。 diff --git a/docs/05_API_AND_WORKFLOW.md b/docs/05_API_AND_WORKFLOW.md new file mode 100644 index 0000000..a8d7064 --- /dev/null +++ b/docs/05_API_AND_WORKFLOW.md @@ -0,0 +1,289 @@ +# 05 API 与业务流程规范 + +## 1. API 命名 + +REST 为主。 + +示例: + +- POST `/api/v1/auth/wechat/login` +- GET `/api/v1/me` +- POST `/api/v1/service-applications` +- GET `/api/v1/service-applications/:publicId` +- POST `/api/v1/admin/service-applications/:publicId/transition` +- POST `/api/v1/admin/service-cases` +- POST `/api/v1/admin/service-cases/:publicId/records` +- POST `/api/v1/admin/follow-ups/:publicId/complete` + +不使用: + +- `/getAllXXX` +- `/doSomething2` +- `/updateStatusDirectly` + +### Phase 1 认证与权限 API + +- `POST /api/v1/auth/wechat/login`:提交一次性 `code`,服务端通过微信 Adapter 换取身份;返回短期 Bearer 会话,不返回 openid/unionid。 +- `GET /api/v1/me`:读取当前微信用户。 +- `POST /api/v1/auth/logout`:撤销当前微信会话。 +- `POST /api/v1/admin/auth/login`:后台账号密码登录;仅允许配置的后台 Origin,成功后设置 HttpOnly、SameSite=Strict Cookie,响应体不含 token。 +- `GET /api/v1/admin/auth/me`:校验 ADMIN 渠道会话和 `admin:access` 权限。 +- `POST /api/v1/admin/auth/logout`:撤销会话并清除 Cookie。 +- `PUT /api/v1/admin/users/:publicId/roles`:需要 ADMIN 渠道与 `roles:manage`,事务替换角色并写审计。 + +### Phase 2 服务申请 API + +- `GET /api/v1/service-categories`:公开读取 V1 已批准服务分类。 +- `POST /api/v1/service-applications`:仅微信 Bearer 会话;每用户入口限流;提交本人或代申请。 +- `GET /api/v1/service-applications?page=1&pageSize=20`:仅返回当前申请人的申请,手机号默认脱敏。 +- `GET /api/v1/service-applications/:publicId`:仅申请人本人可读取;随机 `publicId` 之外仍执行资源归属检查。 +- `GET /api/v1/admin/service-applications`:仅 ADMIN 渠道且具有 `applications:read`;支持申请编号、服务分类、状态及分页筛选。 +- `GET /api/v1/admin/service-applications/:publicId`:同上;读取为提供服务所必需的详情并写审计。 + +Phase 2 不提供状态流转、受理或创建工单接口;这些属于 Phase 3,不能通过直接更新状态提前实现。 + +### Phase 3 服务对象与工单 API + +- `POST /api/v1/admin/service-applications/:publicId/transition`:严格执行 `SUBMITTED → PENDING_CONTACT → ASSESSING`,每次变更事务写历史和审计。 +- `POST /api/v1/admin/service-applications/:publicId/accept`:仅允许从 `ASSESSING` 接单;以 `clientRequestId` 幂等,事务建立或关联服务对象、生成 `ASSIGNED` 工单、指派负责人并将申请推进到 `SERVICE_PLANNED`。 +- `GET /api/v1/admin/beneficiaries`、`GET /:publicId`、`PUT /:publicId`:分页筛选、详情审计和档案维护;列表手机号默认脱敏。 +- `GET /api/v1/admin/beneficiaries/tags`:返回固定服务标签选项。 +- `GET /api/v1/admin/service-cases`、`GET /:publicId`:按资源范围返回工单;无全量权限时仅返回本人被指派工单。 +- `GET /api/v1/admin/service-cases/assignee-options`、`PUT /:publicId/assignees`:列出有效工作人员/志愿者并事务替换协作人员。 +- `POST /api/v1/admin/service-cases/:publicId/start`:仅工单负责人可执行 `ASSIGNED → IN_SERVICE`,同时推进关联申请。 +- `POST /api/v1/admin/service-cases/:publicId/records`、`PATCH /:publicId/records/:recordPublicId`:被指派人员写服务记录;创建幂等,修改使用 `expectedRevision` 乐观锁并保存加密修订快照。 +- `GET /api/v1/service-cases`、`GET /:publicId`、`POST /:publicId/records`:微信渠道志愿者协作接口;只允许读取本人被指派工单,详情返回最小必要字段。 +- `POST /api/v1/service-cases/:casePublicId/files/upload-intents`、`POST /api/v1/files/:publicId/confirm`、`GET /api/v1/files/:publicId/download-url`:私有对象存储短期上传、元数据确认和短期下载地址。 + +### Phase 4 回访闭环 API + +- `POST /api/v1/admin/service-cases/:publicId/complete`:仅工单负责人可完成 `IN_SERVICE` 工单;要求至少一条服务记录,以 `clientRequestId` 幂等,并在一个事务内完成工单、申请状态历史和自动回访。 +- `GET /api/v1/admin/follow-ups/summary`:按资源范围返回今日到期、逾期、未来 7 日和重点关爱待办计数。 +- `GET /api/v1/admin/follow-ups`、`GET /:publicId`:按负责人或全量资源权限分页查看回访;原因和结果仅在授权详情中解密。 +- `POST /api/v1/admin/follow-ups`:创建人工或重点关爱回访;重点关爱类型要求对象档案已设置 `keyCareFlag`。 +- `POST /api/v1/admin/follow-ups/:publicId/complete`:仅任务负责人完成回访;结果为 `NEED_MORE_HELP` 时必须显式确认服务对象/监护人授权并填写新需求。 + +### Phase 5 公益项目、活动与志愿者 API + +- `GET/PUT /api/v1/me/volunteer`:微信用户读取或提交本人志愿者资料;提交使用客户端 UUID 幂等和 `expectedRevision` 乐观锁。已认证志愿者修改姓名或手机号后自动回到待审核并撤销志愿者角色。 +- `GET /api/v1/admin/volunteers`、`GET /:publicId`、`POST /:publicId/review`、`POST /:publicId/restore`:分页审核志愿者并执行 `APPLIED → APPROVED/REJECTED`、`APPROVED → SUSPENDED`、`SUSPENDED → APPROVED`;状态、角色、历史和审计事务一致。 +- `GET /api/v1/public/projects`、`GET /:publicId`:匿名读取已公开且处于已发布/已完成状态的项目;草稿和内部项目不返回。 +- `GET/POST /api/v1/admin/projects`、`GET/PATCH /:publicId`、`POST /:publicId/transition`:按负责人或全量资源权限维护项目;项目严格执行 `DRAFT → PUBLISHED → COMPLETED`,编辑使用 revision 乐观锁。 +- `PUT /api/v1/admin/projects/:publicId/service-cases`:事务替换项目归集的服务工单;无全量权限时禁止把其他项目工单转移到当前项目。 +- `GET /api/v1/admin/project-manager-options`:返回有效 `PROJECT_MANAGER` 工作人员作为受控负责人选项。 +- `GET /api/v1/public/activities`、`GET /:publicId`:匿名读取非草稿活动及公开项目下活动。 +- `GET/POST /api/v1/admin/activities`、`GET/PATCH /:publicId`、`POST /:publicId/transition`:维护活动并严格执行 `DRAFT → PUBLISHED → REGISTRATION_CLOSED → IN_PROGRESS → COMPLETED`,允许未完成活动转为 `CANCELLED`。 +- `POST /api/v1/activities/:publicId/registrations`:仅已认证志愿者报名;事务锁定活动行后校验发布状态、截止时间、有效报名数、容量和 `(activity,user)` 唯一约束。 +- `GET /api/v1/me/activity-registrations`、`POST /:publicId/cancel`:读取或取消本人报名,不暴露他人记录。 +- `GET /api/v1/admin/activity-registrations`、`POST /:publicId/review`、`POST /:publicId/check-in`、`POST /:publicId/service-minutes`:审核报名、活动进行中签到/缺席、活动完成后核定服务分钟;每次写入均有幂等历史和审计。 +- `POST /api/v1/admin/media/upload-intents`、`POST /api/v1/admin/media/:publicId/confirm`:项目/活动封面使用真实 S3 兼容 Adapter 的短期上传和元数据确认;对象存储未配置时明确返回 `STORAGE_UNAVAILABLE`。 + +业务接口默认需要认证;只有显式标注 Public 的接口可以匿名访问。角色授权之外,资源归属/负责范围必须继续通过 resource policy 判断,不能只依赖角色。 + +成功响应统一为 `{ data, requestId }`,失败响应统一为 `{ error: { code, message, details? }, requestId }`。 + +--- + +## 2. 服务申请提交事务 + +伪代码: + +```text +validate input +check idempotency key +resolve applicant user +create or leave beneficiary unlinked +create service_application +create initial status history +create audit log +commit +enqueue notification +return public_id + application_no +``` + +客户端为一次提交生成 UUID v4 `clientRequestId`。数据库以 `(applicant_user_id, client_request_id)` 建唯一约束:同一标识和相同规范化内容返回原结果并标记 `idempotentReplay=true`;同一标识对应不同内容返回 `DUPLICATE_REQUEST`。请求内容指纹使用密钥化、域隔离 HMAC,不保存可离线枚举的原始摘要。 + +Phase 2 必填项为服务分类、申请方式、服务对象称呼、联系人手机号、所在地区、需求描述及显式隐私同意;可选项为方便联系时间、是否需要上门、是否行动不便。服务分类固定为关爱老人、困境妇女、儿童关爱、残障帮扶、生活困难和其他服务。 + +禁止在事务中等待第三方消息服务。 + +--- + +## 3. 受理 + +工作人员: + +1. 打开申请; +2. 权限检查; +3. 联系服务对象; +4. 评估; +5. 可关联已有档案或建立档案; +6. ACCEPTED; +7. 创建 service_case; +8. 指定负责人; +9. 创建后续任务; +10. 通知用户。 + +### Phase 6 已实现接口 + +- 公开内容:`GET /api/v1/content`、`GET /api/v1/content/:publicId`,只返回已发布内容。 +- 内容管理:`/api/v1/admin/content` 创建/查看/编辑草稿和 `transition` 审核迁移,权限为 `content:read/manage/review`。 +- 站内消息:`/api/v1/me/notifications`、读消息和通知偏好,按当前用户强制隔离。 +- 微信投递:`/api/v1/admin/notification-deliveries` 查询、重试、处理,权限为 `notifications:manage`。 + +### Phase 7 已实现接口 + +- `GET /api/v1/admin/statistics/overview`:日期范围最多 366 天,权限 `statistics:read`。 +- `GET /api/v1/admin/statistics/export.csv`:仅汇总无个人字段,权限 `exports:create` 并审计。 +- `GET /health/live`、`GET /health/ready`;`GET /api/v1/admin/system/metrics` 需 `system:manage`。 + +Phase 3 实现中,“关联已有档案”必须同时满足手机号 HMAC 精确匹配和人工确认,不按姓名自动合并。新建未成年人档案必须包含监护人并记录独立授权。 + +--- + +## 4. 服务执行 + +- 申请 `SERVICE_PLANNED → IN_SERVICE`,工单 `ASSIGNED → IN_SERVICE` +- 新增 service_record +- 上传附件 +- 可多次服务 +- 完成时填写 result_summary +- `IN_SERVICE → SERVICE_COMPLETED` +- 自动生成 follow_up_task + +Phase 4 已开放完成入口。工单 `IN_SERVICE → SERVICE_COMPLETED`、申请 `IN_SERVICE → SERVICE_COMPLETED → FOLLOW_UP_PENDING`、三类状态历史、自动回访和审计在单一事务完成;任一步失败则全部回滚。 + +--- + +## 5. 回访 + +完成回访时: + +- 写结果; +- 标记任务完成; +- 如果需要继续帮助,创建 linked application(新工单仍须按既有评估/受理流程生成,不能跳过); +- 记录来源关系; +- 写 audit。 + +`NEED_MORE_HELP` 的事务同时创建新的 consent 和 `SUBMITTED` 申请、记录来源关系、完成回访并将原 `FOLLOW_UP_PENDING` 申请关闭。新申请不覆盖原申请、工单、服务记录或历史。普通完成回访只闭合原申请,不生成新申请。 + +--- + +## 6. 文件上传 + +推荐流程: + +1. 客户端申请上传凭证; +2. 后端检查业务权限和文件约束; +3. 生成短期上传签名或后端代理上传; +4. 上传完成后回调/确认; +5. 后端校验 object metadata; +6. files 表记录; +7. 业务表关联 file_id。 + +安全约束: + +- 文件大小限制; +- MIME 白名单; +- 扩展名白名单; +- object key 随机; +- 禁止使用原始文件名作路径; +- 图片需要时进行安全转码; +- 下载使用短时签名 URL; +- 私有 Bucket 默认。 + +--- + +## 7. 分页 + +统一 cursor 或 page/pageSize。V1 可使用: + +- page >= 1 +- pageSize 默认 20,最大 100 + +返回: + +```json +{ + "items": [], + "page": 1, + "pageSize": 20, + "total": 120 +} +``` + +--- + +## 8. 搜索 + +后台允许按: + +- 业务编号 +- 姓名/称呼 +- 脱敏手机号后四位(视实现) +- 服务类别 +- 状态 +- 时间 +- 项目 +- 负责人 + +敏感全文搜索能力应谨慎开放。 + +--- + +## 9. 导出 + +任何导出: + +- 必须权限校验; +- 记录审计; +- 限制数据范围; +- 大数据量异步生成; +- 文件有过期时间; +- 默认脱敏; +- 导出儿童等敏感信息需要更高权限。 + +--- + +## 10. 错误码 + +至少: + +- AUTH_REQUIRED +- FORBIDDEN +- RESOURCE_NOT_FOUND +- VALIDATION_ERROR +- DUPLICATE_REQUEST +- INVALID_STATE_TRANSITION +- APPLICATION_NOT_FOUND +- CASE_NOT_FOUND +- VOLUNTEER_NOT_APPROVED +- ACTIVITY_FULL +- REGISTRATION_CLOSED +- RESOURCE_CONFLICT +- FILE_TYPE_NOT_ALLOWED +- FILE_TOO_LARGE +- RATE_LIMITED +- INTERNAL_ERROR + +用户端错误文案必须友好,日志保留 requestId 便于排障。 + +--- + +## 11. 健康检查 + +Phase 0 提供: + +- `GET /health` + +成功响应: + +```json +{ + "status": "ok", + "service": "api", + "timestamp": "2026-08-10T12:00:00.000Z" +} +``` + +健康检查不得返回配置值、数据库连接串、密钥或其他敏感信息。业务 API 仍统一使用 `/api/v1` 前缀;基础设施健康检查不属于业务 API。 diff --git a/docs/06_SECURITY_PRIVACY.md b/docs/06_SECURITY_PRIVACY.md new file mode 100644 index 0000000..95feb36 --- /dev/null +++ b/docs/06_SECURITY_PRIVACY.md @@ -0,0 +1,285 @@ +# 06 安全、隐私与合规基线 + +## Phase 6–7 落地补充(2026-08-12) + +内容未经审核不得公开;服务故事发布必须显式确认脱敏,拒绝脚本与事件处理器标记,客户端正文不作为 HTML 执行。审核意见加密,历史只追加。站内消息按 `user_id` 强制隔离;微信凭据、openid、token 和消息正文不得写日志。 + +统计导出仅为汇总事实,不包含手机号、地址、儿童记录或服务描述,要求独立权限并记录范围和 `masked=true` 审计。管理 API 权限扫描、官方 High 级依赖审计、密钥特征扫描和 CodeQL 工作流已建立。 + +> 本文是工程基线,不替代正式法律意见。上线前应由运营主体根据实际收集信息、服务范围和微信平台规则完成隐私政策、用户协议和必要合规审核。 + +## 1. 数据分类 + +### L1 公开 + +公益项目公开介绍、公开活动、组织联系方式。 + +### L2 内部 + +普通内部运营信息、非敏感统计。 + +### L3 敏感业务 + +服务申请详情、家庭困难、住址、联系方式、服务记录。 + +### L4 高敏感 + +儿童个人信息、监护信息、健康/残疾相关详细信息、证件、特殊风险记录。 + +默认 L3/L4 不得公开。 + +--- + +## 2. 最小收集 + +每个字段进入系统前必须回答: + +- 为什么需要? +- 不收集是否仍能提供服务? +- 保存多久? +- 谁能看? +- 是否可脱敏? +- 是否真的需要精确地址/证件? + +没有明确目的不得新增收集项。 + +--- + +## 3. 未成年人 + +不满 14 周岁相关个人信息必须采用更严格保护: + +- 明确监护人角色; +- 记录同意; +- 限制访问人员; +- 默认不公开案例身份; +- 不允许志愿者看到与任务无关的信息; +- 定期审计访问; +- 允许合法更正、删除/撤回等请求按规则处理。 + +--- + +## 4. 加密 + +传输: + +- 全站 HTTPS/TLS。 + +存储: + +- 云盘/数据库基础加密按环境开启; +- 极敏感字段应用层加密; +- 密钥不与数据库同库; +- 密钥通过云 KMS/Secret Manager 或安全环境变量管理。 + +密码: + +- Argon2id 或 bcrypt 强哈希; +- 禁止 MD5/SHA1 直接存密码。 + +--- + +## 5. 手机号和地址 + +显示: + +- 普通列表默认脱敏,例如 138****1234; +- 详细查看必须权限。 + +查询: + +- 如需精确匹配可存 HMAC/hash 辅助索引; +- 明文/密文设计需避免为搜索而降低保护。 + +--- + +## 6. 日志 + +禁止记录: + +- 身份证完整号码 +- 完整手机号 +- openid +- access token +- session token +- 详细住址 +- 儿童档案内容 +- 服务描述全文 +- 上传文件原文 + +允许: + +- public_id +- requestId +- action code +- status +- 耗时 +- 脱敏 actor 信息 + +--- + +## 7. OWASP 基线 + +必须防: + +- SQL injection +- XSS +- CSRF(后台 Cookie 认证时) +- SSRF +- IDOR/BOLA +- path traversal +- unrestricted upload +- mass assignment +- brute force +- credential stuffing +- rate abuse +- insecure direct object reference + +每个资源 API 后端都做授权,不相信前端按钮是否隐藏。 + +--- + +## 8. 限流 + +按场景设置: + +- 登录 +- 验证码 +- 服务申请 +- 文件上传 +- 搜索 +- 导出 +- 微信登录 exchange + +不能只按 IP,必要时组合 user/device/IP。 + +--- + +## 9. 审计事件 + +至少审计: + +- 登录成功/失败 +- 角色权限修改 +- 查看高敏感档案 +- 新增/修改服务对象 +- 服务申请状态变化 +- 工单分配 +- 服务记录修改 +- 导出 +- 删除/归档 +- 隐私同意变化 +- 系统配置变化 + +--- + +## 10. 数据保留 + +不要永久保留所有数据。 + +必须由运营方定义: + +- 服务档案保存期限; +- 未成年人数据期限; +- 日志期限; +- 导出文件期限; +- 已取消授权后的处理; +- 项目验收要求。 + +程序上把保留策略做成可配置任务,而不是散落代码。 + +--- + +## 11. 权限矩阵核心原则 + +### Volunteer + +只能: + +- 看自己被分配任务; +- 看完成服务所需的最小信息; +- 提交服务记录; + 不能: +- 搜索全体服务对象; +- 导出名单; +- 看完整历史档案。 + +### Staff + +按负责范围访问。 + +### Project Manager + +按项目访问。 + +### Admin + +跨项目业务管理,但高敏感导出仍可要求额外权限。 + +### Super Admin + +系统维护,不等于默认业务数据无限查看;高敏感读取同样审计。 + +--- + +## 12. 安全发布门禁 + +上线前必须: + +- npm/pnpm dependency audit +- secret scan +- SAST 基础扫描 +- API 权限测试 +- 越权测试 +- 文件上传测试 +- SQL 注入/XSS 测试 +- 弱密码/暴力登录测试 +- 备份恢复测试 +- staging 冒烟测试 + +发现 Critical/High 安全问题不得上线。 + +--- + +## 13. Phase 1 已落地基线 + +- 后台密码使用 Argon2id;未知用户名执行 dummy verify,降低账号枚举时序差异。 +- 连续 5 次密码失败后锁定 15 分钟;登录和微信 code exchange 均按入口限流。 +- 后台会话只通过 HttpOnly、SameSite=Strict Cookie 传递,并校验后台 Origin;小程序使用短期 Bearer 会话。 +- 数据库只保存会话 token 的带 pepper HMAC;重新登录轮换旧会话,注销、过期、停用账户立即拒绝。 +- openid/unionid 使用 AES-256-GCM 加密并用域隔离 HMAC 建查询索引;密钥只来自环境变量。 +- 默认认证、权限 Guard 与 resource policy 基线已建立;API 不暴露内部 bigint ID。 +- 登录成功/失败、注销和角色变更写入只追加审计;审计元数据拒绝敏感字段名,IP 只保存 HMAC。 +- 后台 CORS 仅允许配置的精确 Origin;输入使用白名单校验并拒绝额外字段。 + +--- + +## 14. Phase 2 已落地基线 + +- 服务对象称呼、联系电话和需求描述按 L3 数据使用 AES-256-GCM 应用层加密;手机号查询索引和请求幂等指纹使用密钥化、域隔离 HMAC-SHA-256。 +- 申请列表默认只返回脱敏手机号;后台详情仅向 ADMIN 渠道且具备 `applications:read` 的人员开放,每次查看均写只追加审计。 +- 小程序申请详情除随机 `publicId` 外继续执行申请人资源归属检查,防止 IDOR/BOLA;后台角色不能通过微信入口读取他人资源。 +- DTO 白名单拒绝额外字段,分页限制为 1–100,手机号和内容长度由服务端验证;提交入口每分钟最多 5 次。 +- 隐私同意、申请、初始状态历史和审计在单一数据库事务中提交;幂等由数据库唯一约束处理并覆盖并发重复提交。 +- 小程序不把服务申请敏感草稿写入本地持久化;网络失败时只在当前页面内保留内容和同一请求标识,方便安全重试。 + +--- + +## 15. Phase 4 已落地基线 + +- 回访原因、结果说明、服务完成总结和回访计划按 L3 数据使用 AES-256-GCM 加密;列表不返回原因或结果正文。 +- `follow_ups:read/manage/complete` 只授予工作人员及以上角色,志愿者不获得回访结果权限;无全量资源权限时只返回本人负责的任务。 +- 服务完成和回访完成都只允许负责人写入,并使用客户端 UUID、请求指纹、状态条件更新和数据库唯一约束处理重试/并发。 +- 继续帮扶必须记录新的明确授权;未成年人由监护人授权。新授权、新申请、原申请关闭、回访状态历史和审计在同一事务提交。 +- 审计元数据仅记录随机公开标识、来源、结果枚举和是否创建继续申请,不记录回访正文、手机号、详细地址或服务描述。 + +--- + +## 16. Phase 5 已落地基线 + +- 志愿者姓名、手机号、可参与时间和审核备注按敏感数据加密;后台列表手机号默认脱敏,授权详情读取写审计,小程序只读取本人资料和报名。 +- 首次申请和重新提交均记录版本化明确同意;已认证志愿者修改姓名或手机号后自动撤销志愿者角色并回到待审核,不能绕过身份复核。 +- 项目负责人只访问本人负责的项目/关联活动;全量资源访问继续由已批准权限控制。无全量权限时禁止把其他项目工单转移进本人项目。 +- 只有 `APPROVED` 志愿者可报名;活动行锁、有效报名计数和数据库唯一约束共同防止并发超额与重复报名。 +- 报名审核、取消、签到、缺席和服务分钟均写不可变历史与审计。服务分钟只允许对已通过、已签到且活动已完成的记录核定,且不得超过活动持续时间。 +- 公益项目和活动公开接口只返回公开状态内容,不返回志愿者名单或服务对象资料。封面未配置对象存储时不生成假地址或假成功。 diff --git a/docs/07_UI_UX.md b/docs/07_UI_UX.md new file mode 100644 index 0000000..921b01e --- /dev/null +++ b/docs/07_UI_UX.md @@ -0,0 +1,148 @@ +# 07 UI/UX 与适老化规范 + +## 1. 视觉原则 + +关键词: + +- 温暖 +- 可信 +- 简洁 +- 清楚 +- 不商业化 +- 不花哨 + +不得做: + +- 密集 Banner +- 过多卡片 +- 首页十几个同级入口 +- 小字号说明堆叠 +- 复杂渐变和强动画 +- 倒计时/诱导点击式设计 + +--- + +## 2. 小程序首页优先级 + +P0: + +1. 我需要帮助 +2. 帮他人申请 + +P1:3. 四大服务类别 4. 我的服务进度 + +P2:5. 公益项目 6. 公益活动 7. 我要做公益 + +宣传内容不能挤压服务入口。 + +--- + +## 3. 适老规范 + +建议基线: + +- 正文字号尽量 >= 16px 等效视觉; +- 主要按钮文字 >= 17px 等效视觉; +- 主要点击区域高度约 48px 以上; +- 按钮间距足够; +- 一屏只做一个主要决定; +- 错误提示紧贴字段; +- 表单支持保存草稿(较长时); +- 不用颜色作为唯一状态表达; +- 图标同时配文字; +- 返回动作清晰; +- 网络失败可重试; +- 提交后明确“已成功”。 + +具体像素可在设计系统中统一,不以本建议替代微信设备适配。 + +--- + +## 4. 表单原则 + +一页最多 5~7 个核心输入。 +复杂申请拆步骤: + +1. 需要什么帮助 +2. 帮谁申请 +3. 联系方式与地区 +4. 需求描述 +5. 确认提交 + +可选信息放“补充信息”。 + +--- + +## 5. 文案 + +使用: + +- “我需要帮助” +- “帮家人申请” +- “工作人员正在联系您” +- “还需要继续帮助” +- “重新提交” + +避免: + +- “创建工单” +- “状态流转” +- “Beneficiary” +- “Record Not Found” +- “非法参数” + +--- + +## 6. 空状态 + +例如没有服务申请: +“您还没有提交过服务申请。如果您或家人需要帮助,可以从这里开始。” + +提供明确按钮。 + +--- + +## 7. 错误恢复 + +提交失败不能清空已填表单。 +上传失败允许单文件重试。 +网络失败保持本地草稿。 +重复点击提交不应生成多个申请。 + +--- + +## 8. 管理后台 + +后台目标:让工作人员每天打开工作台就知道做什么。 + +表格: + +- 默认显示业务必要列; +- 手机、地址默认脱敏; +- 常用筛选固定; +- 详情采用清晰分区; +- 状态颜色+文字; +- 危险操作二次确认并说明影响。 + +禁止为了“企业感”做过密 Dashboard。 + +--- + +## 9. 无障碍 + +- 合理对比度; +- 图片提供语义说明(可用场景); +- 表单 label 明确; +- 键盘操作兼容后台; +- focus 可见; +- 不用仅 hover 才出现核心操作。 + +--- + +## 10. Phase 5 页面落地 + +- 小程序首页仍保持“我需要帮助/帮家人申请”为最高优先级,在其后增加公益项目、公益活动和志愿者中心入口。 +- 项目和活动使用列表→详情的单一主任务结构;活动详情明确显示状态、时间、地点、报名截止、名额和不可报名原因,不加入捐赠或诱导分享入口。 +- 志愿者中心只收集姓名、手机号、地区、可参与时间、固定技能和服务意向,并明确资料使用同意;同页展示资格状态、报名进度与核定服务分钟。 +- 管理端新增志愿者审核、公益项目、活动与报名三个独立导航;危险状态推进二次确认,手机号列表脱敏,所有主要按钮保持至少 44px 点击高度。 +- 沿用已批准的橙色主色、系统字体和功能型布局;采用高对比、可见焦点、文字+状态、加载/空/失败反馈与渐进披露,不引入复杂动画、密集仪表盘或新品牌方向。 diff --git a/docs/08_ENGINEERING_RULES.md b/docs/08_ENGINEERING_RULES.md new file mode 100644 index 0000000..71a63dd --- /dev/null +++ b/docs/08_ENGINEERING_RULES.md @@ -0,0 +1,185 @@ +# 08 工程开发规则 + +## 1. Monorepo + +推荐 pnpm workspace。 + +根命令: + +```bash +pnpm install +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +必须保证 CI 与本地命令一致。 + +--- + +## 2. 包管理 + +- 锁定 package manager 版本; +- 提交 lockfile; +- 不混 npm/yarn/pnpm; +- 国内开发可配置可靠 npm 镜像,但源码不得依赖私有个人镜像; +- CI 应支持官方源回退。 + +--- + +## 3. TypeScript + +- strict: true +- 禁止大面积 any +- 禁止 `@ts-ignore` 掩盖错误;特殊情况写原因 +- API contracts 明确类型 +- 日期统一 ISO 8601 + +--- + +## 4. Git + +分支: + +- main +- develop(可选) +- feature/* +- fix/* +- chore/* + +Commit 建议 Conventional Commits。 + +每个 PR: + +- 变更摘要 +- 测试结果 +- 截图(UI) +- migration 说明 +- 风险 +- 回滚方案 + +--- + +## 5. 环境变量 + +`.env.example` 只能放占位符。 + +示例: + +```env +APP_ENV=development +DATABASE_URL= +REDIS_URL= +WECHAT_APP_ID= +WECHAT_APP_SECRET= +COS_BUCKET= +COS_REGION= +COS_SECRET_ID= +COS_SECRET_KEY= +SESSION_SECRET= +FIELD_ENCRYPTION_KEY= +``` + +真实密钥不得入 Git。 + +--- + +## 6. 数据库 Migration + +规则: + +- 每次 schema 修改一份 migration; +- migration 可审查; +- 生产不允许 ORM 自动 `synchronize=true`; +- 大表变更考虑锁; +- destructive migration 分阶段。 + +--- + +## 7. 代码质量 + +函数: + +- 单一职责; +- 复杂度高就拆; +- 业务规则命名表达含义; +- 不写 1000 行 service; +- 不复制粘贴权限逻辑。 + +必须有: + +- lint +- format +- typecheck +- unit test +- integration test + +--- + +## 8. 错误处理 + +后端: + +- 业务错误使用统一 Domain/Application Error; +- 未预期错误不把 stack 返回用户; +- requestId 贯穿。 + +前端: + +- 业务错误直接友好提示; +- 网络错误可重试; +- 401 统一重新登录; +- 403 明确无权限; +- 5xx 提供 requestId。 + +--- + +## 9. 第三方依赖 + +新增依赖前: + +1. 是否真有必要? +2. 是否长期维护? +3. License 是否可用? +4. 是否显著增大 bundle? +5. 是否国内网络可获取? +6. 能否用现有依赖实现? + +避免“为了 10 行代码引入一个包”。 + +--- + +## 10. Codex 禁止行为 + +- 不读文档直接重构; +- 看到旧代码就全量重写; +- 为通过测试删除测试; +- 用 mock 替代真实功能后宣称完成; +- 注释掉权限; +- 关闭类型检查; +- `catch {}` 吞错误; +- 生产环境 hardcode; +- 把敏感值写 fixtures; +- 删除 migration 历史; +- 改 API 不更新前端和 contracts; +- 自己决定产品删减。 + +--- + +## 11. Definition of Done + +一个任务只有同时满足以下条件才算完成: + +- 功能实现; +- 正常路径测试; +- 异常路径测试; +- 权限测试; +- 数据 migration 完成; +- 文档更新; +- lint 通过; +- typecheck 通过; +- test 通过; +- build 通过; +- 无新 High/Critical 漏洞; +- `10_TASK_STATE.md` 已回写。 diff --git a/docs/09_TEST_ACCEPTANCE.md b/docs/09_TEST_ACCEPTANCE.md new file mode 100644 index 0000000..f0f0a08 --- /dev/null +++ b/docs/09_TEST_ACCEPTANCE.md @@ -0,0 +1,157 @@ +# 09 测试与验收 + +## Phase 6–7 验收补充(2026-08-12) + +自动化覆盖草稿公开隔离、审核发布历史、服务故事隐私确认、危险内容拒绝、消息 IDOR、订阅授权、统计未认证拒绝、脱敏汇总导出和导出审计。隔离 MySQL 部署 8 个 migration 后全量 34 个集成测试通过。 + +生产工程验收:权限扫描通过;官方源 High 级依赖审计无已知漏洞;密钥特征扫描无匹配;schema drift 无差异;基础/staging/production Compose 校验与 API Alpine 镜像构建通过;备份恢复到临时库后源/恢复库均为 42 张表并完成清理。 + +真实 staging、UAT、微信真机联调和生产部署不能由本地自动化替代,继续按发布清单留存人工证据。 + +## 1. 测试金字塔 + +### Unit + +业务规则、状态机、权限策略、计算。 + +### Integration + +API + DB,重点测试事务、唯一约束、权限和状态流转。 + +### E2E + +关键业务闭环。 + +--- + +## 2. P0 E2E 场景 + +### E2E-001 本人申请 + +用户登录 → 选择老年服务 → 填写需求 → 提交 → 获得编号 → 我的申请可见。 + +### E2E-002 防重复 + +用户连续点击提交两次 → 只能产生一个业务申请。 + +### E2E-003 代申请 + +家属为老人申请 → 正确保存申请人与服务对象关系。 + +### E2E-004 工作人员受理 + +工作人员登录 → 查看待处理 → 联系/评估 → 建档 → 受理 → 创建工单。 + +### E2E-005 服务执行 + +负责人 → 开始服务 → 添加两条服务记录 → 完成 → 自动生成回访任务。 + +### E2E-006 回访继续帮扶 + +回访选择“需要继续帮助” → 生成关联后续任务/申请,原历史不丢失。 + +Phase 4 自动化验收还必须覆盖:无服务记录禁止完成、服务完成/自动回访事务一致、相同请求安全重放而变更内容冲突、工作人员仅看本人回访、项目负责人可看全量、重点关爱待办统计、继续帮扶新授权和关联申请唯一、原申请关闭且原历史保留。 + +### E2E-007 越权 + +志愿者 A 访问志愿者 B 的工单 → 403。 + +### E2E-008 用户越权 + +用户 A 猜测用户 B 的 public_id → 404/403,不泄露存在性。 + +### E2E-009 活动报名 + +报名 → 重复报名被唯一约束阻止 → 人数达到上限后不可继续报名。 + +Phase 5 自动化验收还必须覆盖:未认证用户禁止报名、草稿项目不能公开读取、志愿者申请/审核与角色授予事务一致、项目/活动状态机、报名请求安全重放、报名审核重放、签到重放、容量行锁、服务时长只来自已通过且已签到的已完成活动、历史与审计不重复写入。 + +### E2E-010 文件 + +允许图片上传;exe/php/html 等危险类型拒绝;超限拒绝;私有文件无授权无法下载。 + +--- + +## 3. 安全验收 + +必须测试: + +- IDOR +- broken access control +- mass assignment +- SQLi +- stored/reflected XSS +- CSRF(后台) +- upload bypass +- rate limit +- brute force +- token expiry +- session fixation +- sensitive log leakage + +--- + +## 4. 可靠性验收 + +模拟: + +- Redis 不可用; +- COS 暂时失败; +- 微信消息发送失败; +- 数据库瞬时错误; +- 客户端重复请求; +- 网络超时。 + +要求: + +- 主业务不产生半状态; +- 可重试; +- 失败可追踪; +- UI 不假成功。 + +--- + +## 5. 性能目标(V1 建议基线) + +不是大型互联网项目,不盲目追极限。 + +建议: + +- 常规 API P95 < 500ms(不含第三方和大文件) +- 后台列表首屏合理数据量 < 2s +- 小程序首页优先内容快速可见 +- 100 并发常规请求下无明显错误率上升 +- 数据库常用列表避免 N+1 + +具体目标可根据云资源实测调整。 + +--- + +## 6. UAT 验收人员 + +至少模拟: + +- 65+ 老年服务对象 +- 家属代申请 +- 普通工作人员 +- 项目负责人 +- 志愿者 +- 管理员 + +UAT 不只让开发人员自测。 + +--- + +## 7. V1 上线门槛 + +必须全部满足: + +- P0 E2E 全通过; +- 无 Critical/High 已知安全漏洞; +- 备份成功; +- 恢复演练通过; +- staging 走完整闭环; +- 隐私政策/授权页面就绪; +- 微信小程序审核所需材料就绪; +- 监控和告警正常; +- 可回滚上一版本。 diff --git a/docs/10_TASK_STATE.md b/docs/10_TASK_STATE.md new file mode 100644 index 0000000..00e57ba --- /dev/null +++ b/docs/10_TASK_STATE.md @@ -0,0 +1,241 @@ +# 10 任务状态(Codex 每次必须维护) + +> 这是项目“长记忆”的核心文件之一。禁止删除历史,仅更新状态并添加记录。 + +## 当前阶段 + +`PHASE_7_IMPLEMENTATION_COMPLETE - 外部发布验收待执行` + +## 当前版本目标 + +`V1.0 MVP:跑通公益服务主闭环` + +--- + +## 阶段任务 + +### Phase 0:工程初始化 + +- [x] 建立 monorepo +- [x] 建立 miniprogram +- [x] 建立 admin-web +- [x] 建立 api +- [x] 建立 shared contracts +- [x] Docker Compose 本地环境 +- [x] MySQL / Redis / MinIO +- [x] ESLint / Prettier / TSConfig +- [x] CI +- [x] health check +- [x] 环境变量模板 +- [x] AGENTS.md + +### Phase 1:认证与权限 + +- [x] 微信登录 +- [x] 后台登录 +- [x] user +- [x] RBAC +- [x] resource policy +- [x] audit baseline + +### Phase 2:服务申请 + +- [x] 服务分类 +- [x] 本人申请 +- [x] 代申请 +- [x] 我的申请 +- [x] 状态历史 +- [x] 防重复提交 +- [x] 后台申请列表/详情 + +### Phase 3:服务对象与工单 + +- [x] beneficiary 档案 +- [x] 联系人/监护人 +- [x] 标签 +- [x] 工单 +- [x] 分配工作人员 +- [x] 志愿者协作 +- [x] 服务记录 +- [x] 附件 + +### Phase 4:回访闭环 + +- [x] 回访任务 +- [x] 自动创建 +- [x] 完成回访 +- [x] 继续帮扶 +- [x] 工作台待办 + +### Phase 5:公益项目/活动/志愿者 + +- [x] 项目 CRUD/发布 +- [x] 活动 +- [x] 活动报名 +- [x] 志愿者申请/审核 +- [x] 服务时长 + +### Phase 6:内容与通知 + +- [x] 公益动态 +- [x] 机构介绍 +- [x] 内容审核状态 +- [x] 站内消息 +- [x] 微信订阅通知 Adapter + +### Phase 7:统计、安全与上线 + +- [x] 基础统计 +- [x] 导出 +- [x] 权限扫描 +- [x] 安全测试 +- [x] 备份恢复 +- [ ] staging +- [ ] UAT +- [ ] production deployment + +--- + +## 当前正在处理 + +Phase 6 及 Phase 7 可自动化开发与本地生产工程验证均已完成。Phase 7 的 staging、UAT 和 production deployment 仍需真实环境、微信配置、合规材料及人工审批,保持未完成;当前不得宣称已生产上线。 + +--- + +## 最近完成记录 + +格式: + +```text +YYYY-MM-DD +任务: +完成: +测试: +涉及 migration: +文档变化: +遗留问题: +``` + +2026-08-12 + +任务:Phase 7 统计、安全与上线工程。 + +完成:实现基于真实事实表的申请数、已受理数、已完成服务、服务人次、独立服务对象、服务分类、志愿者数、活动数和志愿服务分钟统计,日期范围最多 366 天,不支持人工覆盖;实现独立 `exports:create` 权限、无个人字段的汇总 CSV 和导出审计。新增数据库 readiness、进程 liveness、受保护的请求量/5xx/p95/内存指标;新增权限扫描、官方依赖审计和 CodeQL/密钥 CI。新增每日 MySQL 备份、SHA-256、保留期和显式目标恢复脚本;新增 Nginx TLS/HSTS/限流配置、staging/production Compose 覆盖、immutable image 预检、健康/公开内容冒烟、监控告警规则和发布/回滚手册。完成管理端统计看板。 + +测试:format check、lint、全仓 typecheck、27 个 unit tests、34 个真实 MySQL integration tests、全仓 production build、8 个 migration 重复 deploy、schema drift、权限扫描、npm 官方 High 级依赖审计、密钥特征扫描、基础/staging/production Compose 校验和 API Alpine 镜像构建全部通过。统计集成测试覆盖未认证拒绝、真实查询、汇总导出无手机号及导出审计。隔离测试库完成备份恢复演练:SQL SHA-256 为 `3aa51c8674fda55d3b720e93ce3e1e77781649b74e65c1c977fc6ef1af68c367`,源库和临时恢复库均为 42 张表,核验后临时恢复库已删除。 + +涉及 migration:`20260812170000_phase7_statistics_security` 和 `20260812173000_phase7_fk_names`;已在独立 `lehuo_test` 库部署,共 8 个 migration,无 schema drift。 + +文档变化:新增 D-015、D-016;更新数据模型、API/工作流、安全隐私、测试验收、生产就绪计划、发布清单、部署运行手册和本任务状态。 + +遗留问题:staging、UAT 和 production deployment 未执行。仍需有效微信 AppID/AppSecret、六类订阅模板、合法 HTTPS 域名/证书、微信隐私声明与真机授权;生产 MySQL/Redis/私有对象存储、镜像仓库、告警接收人、异地备份目标;隐私政策/用户协议/数据保留确认、UAT 测试人员、发布窗口和审批人。上述外部项未以本地或测试值冒充完成。 + +2026-08-12 + +任务:Phase 6 内容与通知。 + +完成:新增机构介绍、公益动态、服务故事、通知公告和常见问题统一内容模型;实现 `DRAFT → PENDING_REVIEW → PUBLISHED → ARCHIVED` 严格状态机、退回草稿、revision 乐观锁、UUID 幂等键、加密审核意见、不可变状态历史和审计;所有内容审核后公开,服务故事发布强制隐私脱敏确认并拒绝危险 HTML/脚本标记。实现站内消息、未读状态、本人资源隔离、通知偏好和显式授权记录;申请提交/受理、服务完成/待回访和活动报名结果在原业务事务中可靠写入站内消息。实现微信订阅通知 Adapter、模板映射、数据库投递队列、抢占处理、指数退避、最多五次重试、死信和管理端人工重试;外部发送失败不回滚主业务,未配置真实微信参数时明确报告不可用。完成管理端内容审核与通知投递工作台,以及小程序公益资讯、机构介绍、内容详情、我的消息和通知设置页面。 + +测试:format check、lint、全仓 typecheck、27 个 unit tests、33 个真实 MySQL integration tests和全仓 production build 全部通过。Phase 6 集成测试覆盖草稿公开隔离、审核发布、不可变历史、服务故事隐私确认、危险内容拒绝、消息 IDOR 隔离和订阅偏好授权;Phase 1–5 全量回归通过。 + +涉及 migration:`apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql`;已在独立 `lehuo_test` 库部署,共 6 个 migration。 + +文档变化:更新共享契约、环境变量模板和本任务状态;Phase 6 API/模型/安全细节随 Phase 7 结项统一同步到专题文档。 + +遗留问题:无 Phase 6 代码阻塞。真实微信 AppID/AppSecret、六类订阅模板、用户微信平台弹窗授权、合法 HTTPS 域名和真机联调按用户安排后期提供;Adapter 未以测试值冒充真实发送完成。 + +2026-08-11 + +任务:Phase 5 公益项目、活动与志愿者。 + +完成:新增志愿者资料/技能/意向/状态历史、公益项目及状态历史、活动及状态历史、活动报名及不可变事实历史、项目工单归集、项目/活动封面业务类型和 Phase 5 权限 migration;实现志愿者申请、显式同意、revision 乐观锁、审核/暂停/恢复与角色事务同步,身份字段变更自动回待审;实现项目 CRUD、负责人范围、`DRAFT → PUBLISHED → COMPLETED`、公开边界与工单归集;实现活动 CRUD、发布/关报名/进行/完成/取消状态机;实现仅认证志愿者报名、活动行锁容量校验、截止时间、数据库唯一约束和幂等重放;实现报名审核、本人取消、签到/缺席、完成后服务分钟核定及历史审计。项目/活动封面复用真实 S3 兼容 Adapter,未配置时明确不可用。完成管理端志愿者审核、项目、活动与报名工作台,以及小程序公开项目/活动、在线报名、志愿者中心和个人服务事实页面。未进入 Phase 6。 + +测试:format check、lint、全仓 typecheck、27 个 unit tests、30 个真实 MySQL integration tests、全仓 production build、Docker Compose 示例配置校验、migration 重复 deploy、schema drift 检查、npm 官方 High 级 dependency audit、密钥特征扫描和 API Alpine Docker 镜像构建全部通过。Phase 5 集成测试覆盖项目/活动状态机、草稿公开隔离、未认证报名拒绝、重复报名安全重放、容量边界、报名审核/签到重放、完成后服务时长、不可变历史和审计;Phase 1–4 全量回归通过。 + +涉及 migration:`apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql`;已在独立 `lehuo_test` 库部署,重复 deploy 无待执行 migration,无 schema drift。 + +文档变化:更新应用 README、数据模型、API/工作流、UI/UX、安全隐私、测试验收、生产就绪计划、ADR 和本任务状态;新增 D-014。 + +遗留问题:无 Phase 5 代码阻塞。真实微信 AppID/AppSecret、合法 HTTPS 域名、生产私有对象存储密钥/CORS、微信开发者工具/真机、staging/UAT 和隐私法务配置按用户安排后期提供,未用测试值冒充完成。生产仍被 Phase 6–7、监控告警、备份恢复演练和发布审批阻断;详见 `17_PRODUCTION_READINESS_PLAN.md`。 + +2026-08-11 + +任务:Phase 4 回访闭环与生产就绪差距规划。 + +完成:新增回访任务、状态历史、继续帮扶来源关系、案件完成幂等字段及 `follow_ups:read/manage/complete` 权限 migration;实现负责人完成服务、至少一条服务记录前置校验,以及工单完成、申请 `IN_SERVICE → SERVICE_COMPLETED → FOLLOW_UP_PENDING`、自动回访和审计的单事务提交;实现人工/重点关爱回访、负责人/全量资源范围、今日/逾期/未来 7 日/重点关爱工作台、加密回访原因与结果、幂等完成;实现 `NEED_MORE_HELP` 新的显式授权、关联 `SUBMITTED` 申请、原申请关闭、历史和审计的单事务提交,不跳过既有评估/受理流程;完成管理端工单完结表单与回访工作台。新增生产就绪差距和 Phase 4–7 路线文档,未进入 Phase 5。 + +测试:format check、lint、全仓 typecheck、27 个 unit tests、28 个真实 MySQL integration tests、全仓 production build、Docker Compose 示例环境配置校验、migration 重复 deploy、schema drift 检查和 npm 官方 high-level dependency audit 全部通过;Phase 4 集成测试覆盖无记录禁止完成、事务自动回访、幂等冲突、资源范围和继续帮扶独立授权/关联申请。 + +涉及 migration:`apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql`;已在独立 `lehuo_test` 库部署,无待执行 migration,无 schema drift。 + +文档变化:新增 `17_PRODUCTION_READINESS_PLAN.md`;更新数据模型、API/工作流、安全隐私、测试验收、ADR 和本任务状态;修正 staff/beneficiary 显示名及接单幂等字段在数据模型文档中的归属错误;新增 D-013。 + +遗留问题:无 Phase 4 代码阻塞。生产仍被 Phase 5–7、真实微信 AppID/AppSecret 与合法 HTTPS 域名、生产数据库/Redis/私有对象存储、监控告警、隐私法务确认、staging/UAT、备份恢复演练和发布审批阻断;详见 `17_PRODUCTION_READINESS_PLAN.md`。这些外部项未用测试值冒充完成。管理端截图级浏览器验收沿用已知本机隔离限制,本阶段通过类型检查、自动化测试和 production build 验证。 + +2026-08-11 + +任务:Phase 3 服务对象与工单。 + +完成:新增服务对象、联系人/监护授权、标签、工单、多人指派、工单/申请状态历史、服务记录、记录修订和私有文件模型及 migration;实现 `SUBMITTED → PENDING_CONTACT → ASSESSING` 严格受理、事务化幂等接单、手机号 HMAC + 人工确认关联档案、未成年人监护授权、资源范围和志愿者最小字段策略、负责人启动服务、幂等服务记录和 revision 乐观锁;实现 S3 兼容私有对象存储 Adapter、短期上传/下载签名和对象元数据确认;完成管理端申请受理、档案、工单、指派、记录及照片 UI,以及小程序权限感知的协作任务、最小详情和记录 UI。未实现 Phase 4 的完成服务和自动回访。 + +测试:format check、lint、全仓 typecheck、26 个 unit tests、25 个真实 MySQL integration tests、全仓 production build、Docker Compose config、npm 官方 high-level dependency audit、密钥特征扫描、API Alpine Docker 镜像构建全部通过;Phase 3 migration 已在独立 `lehuo_test` 库部署且重复 deploy 无待执行 migration。 + +涉及 migration:`apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql`。 + +文档变化:更新 API/工作流、数据模型、ADR、环境模板、API/管理端/小程序/Docker 运行说明和本任务状态;新增 D-012。 + +遗留问题:无 Phase 3 代码阻塞。真实微信 AppID/AppSecret、合法 HTTPS 域名、对象存储私有 Bucket/最小权限密钥/CORS 以及微信开发者工具与真机联调按用户安排后期提供;生产对象存储未配置时附件 API 会明确返回不可用,不以测试 Adapter 冒充生产。管理端截图级浏览器验收沿用已知本机隔离网络限制,本轮通过类型检查、自动化测试和生产构建验证。 + +2026-08-11 + +任务:Phase 2 服务申请。 + +完成:建立服务分类、申请方式与完整申请状态共享契约;新增 consents、service_applications、只追加状态历史和 applications:read migration;实现本人/代申请、五步表单、提交成功、我的申请与状态时间线;实现事务化同意/申请/初始历史/审计、AES-256-GCM 敏感字段加密、域隔离 HMAC 查询索引及请求指纹、数据库唯一约束与并发幂等、申请人资源归属校验、提交限流;实现后台权限筛选、分页脱敏列表和详情查看审计。未实现 Phase 3 档案、受理、状态流转、工单或分配。 + +测试:format check、lint、全仓 typecheck、26 个 unit tests、17 个真实 MySQL integration tests、全仓 production build、Docker Compose config、pnpm high-level dependency audit、密钥特征扫描、API Alpine Docker 镜像构建全部通过;Phase 2 migration 已在独立 `lehuo_test` 库成功部署。最终重复执行 migration 检查时 Prisma Windows schema engine 被本机策略以 EPERM 拦截,已在同一已迁移隔离库直接重跑全部集成测试并通过。应用内浏览器无法从隔离网络访问仅绑定回环地址的本地服务器;出于安全考虑未将管理接口暴露到 `0.0.0.0`,开发服务器均已停止。 + +涉及 migration:`apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql`。 + +文档变化:更新小程序、管理端、API 与 Docker README;更新数据模型、API/工作流、安全隐私、ADR 和本任务状态;新增 D-011。 + +遗留问题:无 Phase 2 代码阻塞。真实微信 AppID/AppSecret、合法 HTTPS 域名、微信开发者工具/真机联调按用户安排后期提供;管理端截图级浏览器验收受本机隔离网络限制,自动化逻辑测试和生产构建已通过。 + +2026-08-10 + +任务:Phase 1 认证与权限。 + +完成:建立 Prisma 7/MySQL 认证数据模型和 migration;实现统一用户、微信 code exchange Adapter、后台 Argon2id 登录、数据库 opaque session、小程序 Bearer 会话、后台 HttpOnly Cookie、会话轮换/注销/过期与停用校验、登录锁定和限流;实现 RBAC、权限 Guard、resource policy 基线、角色事务替换和最后一名超级管理员保护;实现敏感字段 AES-256-GCM/HMAC、统一响应/错误/requestId、审计基线、首次管理员安全初始化;完成管理端登录/恢复/注销 UI 和小程序登录/重试 UI;CI 增加隔离 MySQL 集成测试,并修复 js-yaml High 漏洞。 + +测试:format check、lint、typecheck、19 个 unit tests、9 个真实 MySQL integration tests、全仓 build、API Alpine Docker 镜像构建、Docker Compose config、pnpm high-level dependency audit、密钥特征扫描全部通过;migration 在独立 `lehuo_test` 库成功部署。应用内浏览器视觉核验因本机 Browser 运行时路径权限被环境阻止,开发服务器已停止;自动化 UI 逻辑测试和生产构建通过。 + +涉及 migration:`apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql`。 + +文档变化:更新根 README、客户端与 Docker 运行说明、数据模型、API/工作流、安全隐私、ADR 和本任务状态;新增 D-009、D-010。 + +遗留问题:无 Phase 1 代码阻塞。真实微信平台 exchange 仍需在具备有效 AppID/AppSecret、合法 HTTPS 域名和微信环境时做联调;本机应用内浏览器权限问题导致本轮未获得登录页截图级视觉验收。 + +2026-08-10 + +任务:Phase 0 仓库初始化与技术基线。 + +完成:初始化 Git main 分支与 pnpm monorepo;创建原生 TypeScript 微信小程序、Vue 3 管理后台、NestJS API、共享 contracts;建立统一 ESLint/Prettier/TypeScript/测试/构建命令;创建 MySQL 8.4.8、Redis 7.4、MinIO 本地 Compose;创建 API Dockerfile、GET /health、.env.example、根 AGENTS.md、CI 和模块说明。 + +测试:format check、lint、typecheck、4 个 unit tests、1 个 integration test、build、dependency audit 全部通过;Docker Compose 配置通过,MySQL/Redis/MinIO 均 healthy;本机 API 与容器化 API 的 /health 冒烟测试通过。 + +涉及 migration:无(Phase 0 未创建业务 schema)。 + +文档变化:更新 docs/10_TASK_STATE.md、docs/05_API_AND_WORKFLOW.md;新增根 README.md 与 infra/docker/README.md。 + +遗留问题:无 Phase 0 阻塞问题。尚未开始任何 Phase 1 认证、权限或业务功能。 + +--- + +## 已知问题 + +无已知 Phase 5 代码问题。生产阻断项和人工配置见 `17_PRODUCTION_READINESS_PLAN.md`。 + +--- + +## 下一步 + +等待用户提供或确认外部生产资源后,依次执行 staging 全闭环、非开发人员 UAT、真实微信联调和 production deployment;不得跳过发布清单或审批。 diff --git a/docs/11_DECISIONS.md b/docs/11_DECISIONS.md new file mode 100644 index 0000000..8abb2ac --- /dev/null +++ b/docs/11_DECISIONS.md @@ -0,0 +1,211 @@ +# 11 架构与产品决策日志(ADR 简化版) + +> Codex 不得删除已批准决策。新决策追加。 + +## D-001 产品架构 + +状态:ACCEPTED + +决定: +V1 使用“微信小程序 + Web 管理后台 + 单体 API + MySQL + Redis + 对象存储”。 + +原因: +低复杂度、易部署、易维护、适合当前组织规模。 + +--- + +## D-002 后端架构 + +状态:ACCEPTED + +决定: +模块化单体,不采用微服务。 + +触发重新评估条件: + +- 单体部署已形成明确性能瓶颈; +- 团队规模显著扩大; +- 某模块有独立扩容/安全隔离的客观需求。 + +--- + +## D-003 主技术语言 + +状态:ACCEPTED + +决定: +优先 TypeScript 全栈。 + +原因: +前后端共享类型、Codex 开发一致性高、维护人员切换成本较低。 + +--- + +## D-004 数据库 + +状态:ACCEPTED + +决定: +MySQL 作为业务事实数据库。 +Redis 不保存不可恢复的唯一业务事实。 + +--- + +## D-005 文件 + +状态:ACCEPTED + +决定: +生产文件进入私有对象存储,通过授权临时访问;不持久化在应用容器本地盘。 + +--- + +## D-006 V1 范围 + +状态:ACCEPTED + +决定: +V1 首先实现服务闭环、志愿者、项目活动、内容和基础统计,不做复杂 AI、地图、公开募捐、自研 IM。 + +--- + +## D-007 隐私 + +状态:ACCEPTED + +决定: +个人敏感信息采取最小收集、最小权限、默认脱敏、操作审计;儿童相关信息更严格。 + +--- + +## D-008 UI + +状态:ACCEPTED + +决定: +优先普通群众与适老使用,不追求炫技。 + +--- + +## D-009 数据访问与迁移 + +状态:ACCEPTED + +决定: +Phase 1 使用 Prisma 7 与 MariaDB/MySQL driver adapter 访问 MySQL;所有 schema 变化通过 Prisma migration SQL 提交,运行时不使用自动同步。 + +原因: +保持类型安全、显式迁移和模块化单体的一致性,并确保 CI 与部署可重复执行 migration。 + +--- + +## D-010 认证与会话 + +状态:ACCEPTED + +决定: +微信登录通过可替换 Adapter 调用官方 code exchange;后台密码使用 Argon2id。会话采用数据库支持的随机 opaque token,数据库只保存带 pepper 的 HMAC。小程序使用短期 Bearer token,后台使用 HttpOnly、SameSite=Strict Cookie 并校验 Origin。 + +原因: +V1 不引入额外身份供应商或复杂 token 基础设施,同时支持主动注销、轮换、停用和审计,且避免前端持久化后台凭据。 + +--- + +## D-011 服务申请隐私与幂等 + +状态:ACCEPTED + +决定: +Phase 2 使用 PRD 已批准的六类服务分类和本人/代申请方式。申请敏感字段采用 AES-256-GCM,查询索引及规范化请求指纹采用域隔离 HMAC-SHA-256;以申请人和客户端请求 UUID 的数据库唯一约束实现幂等。隐私同意、申请、初始状态历史和审计必须事务一致。 + +原因: +在不提前引入 Phase 3 档案和工单的前提下,确保弱网重试不会重复建单,并降低数据库泄露后联系方式、需求内容或可枚举请求摘要被还原的风险。 + +--- + +## D-012 Phase 3 接单、记录与文件一致性 + +状态:ACCEPTED + +决定: +申请接单采用数据库事务和客户端 UUID 幂等:从评估状态建立或关联服务对象、写监护授权、生成工单、指派人员、追加申请/工单历史及审计必须原子完成。已有档案只允许在手机号 HMAC 精确匹配且工作人员明确确认后关联。服务记录创建幂等,修改使用 revision 乐观锁并保存加密修订快照。文件通过 S3 兼容 Adapter 进入私有对象存储,后端只签发短期 URL 并在确认时校验元数据。 + +Phase 3 只开放 `ASSIGNED → IN_SERVICE`;完成服务与自动回访保持为 Phase 4 的事务边界,禁止提前拆开实现。 + +原因: +避免弱网重试重复建档/建单、同名误合并、并发覆盖服务记录、对象存储永久公开,以及在回访能力尚未实现时产生无法闭环的“已完成”工单。 + +--- + +## D-013 Phase 4 回访与继续帮扶一致性 + +状态:ACCEPTED + +决定: +完成服务要求至少一条服务记录,并由工单负责人以客户端 UUID 幂等提交。工单完成、申请进入待回访、状态历史、自动回访和审计必须原子提交。回访结果为“需要继续帮扶”时,不直接生成工单;必须先取得新的显式授权,创建保留原申请和来源回访关系的 `SUBMITTED` 新申请,再沿用既有评估/受理流程。新授权、新申请、回访完成、原申请关闭、历史和审计必须在同一事务提交。 + +人工和重点关爱回访使用同一任务模型;重点关爱回访仅适用于已标记对象。回访正文加密,列表最小化,志愿者不获得回访权限;普通工作人员只访问本人负责任务,具有全量资源权限的管理角色可查看工作台全量范围。 + +原因: +防止服务完成但回访丢失、弱网重试产生重复任务、继续帮扶覆盖原历史或绕过评估受理,同时降低回访结论和脆弱人群信息泄露风险。 + +--- + +## D-014 Phase 5 志愿参与状态机与服务事实 + +状态:ACCEPTED + +决定: +项目采用 V1 最小状态 `DRAFT → PUBLISHED → COMPLETED`;活动采用已批准的 `DRAFT/PUBLISHED/REGISTRATION_CLOSED/IN_PROGRESS/COMPLETED/CANCELLED`;报名采用 `PENDING/APPROVED/REJECTED/CANCELLED`,签到独立采用 `NOT_CHECKED_IN/CHECKED_IN/ABSENT`。每个业务状态变化均以客户端 UUID 幂等,在同一事务写条件更新、不可变历史和审计。 + +只有 `APPROVED` 志愿者可报名。报名事务锁定活动事实后校验发布状态、截止时间、容量和 `(activity,user)` 唯一约束。服务分钟只从已通过、已签到且活动已完成的报名记录产生,保留修改前后历史;志愿者修改姓名或手机号后必须重新审核并暂时撤销志愿者角色。 + +项目类别复用既有服务分类;项目可归集既有服务工单,但无全量权限的负责人不能转移其他项目工单。项目/活动封面复用 S3 兼容对象存储 Adapter 和短时 URL;外部存储未配置时允许无封面业务继续运行,但不得伪造上传或公开地址。Phase 5 不创建通知、证书、培训、团队、复杂排班或公开募捐能力。 + +原因: +保持 V1 范围最小且与既有服务事实一致,防止并发超额、重复报名、资格绕过、不可追溯服务时长和跨项目资源转移,同时把外部对象存储配置与核心志愿参与链路解耦。 + +--- + +## D-015 Phase 6 内容审核与可靠通知 + +状态:ACCEPTED + +决定: +V1 五类内容共用 `DRAFT → PENDING_REVIEW → PUBLISHED → ARCHIVED` 状态机;退回进入草稿,已发布内容只能归档,不允许编辑后直接公开。所有变更使用 UUID 幂等、revision 乐观锁、不可变历史和审计。服务故事发布必须显式确认隐私脱敏,正文仅按纯文本/Markdown 保存,不执行用户 HTML。 + +站内消息与外部投递分离。业务状态变化在原数据库事务创建幂等站内消息;微信订阅通知仅在用户明确开启偏好后创建数据库投递任务,由 Adapter 异步发送并指数退避,最多五次后进入死信。微信失败不得回滚主业务,未配置平台参数时明确不可用。 + +原因: +避免草稿或未经隐私审核的个案内容泄露,保证通知事实不因外部平台故障丢失,并维持外部供应商与业务服务解耦。 + +--- + +## D-016 Phase 7 统计、导出与发布工程 + +状态:ACCEPTED + +决定: +V1 统计直接从业务事实表计算,不建立可人工覆盖的统计表。统计查询最多 366 天;导出仅提供汇总且默认不包含个人字段,使用独立权限并记录审计。运行面提供 liveness、数据库 readiness 和受保护的请求量/5xx/p95/资源指标。 + +生产发布使用不可变镜像标签,固定执行 staging、migration/备份预检、冒烟、UAT、生产备份、部署、健康检查、观察与按上一镜像回滚。MySQL 每日备份、SHA-256 校验、异地留存和季度独立库恢复演练。真实 staging、UAT、微信联调和生产发布必须等待对应环境、资质与人工审批,不以本地检查冒充。 + +原因: +确保公益服务数据口径可追溯,降低导出泄露面,并把可自动验证的生产工程与必须由真实环境/责任人完成的发布审批清晰分开。 + +--- + +## 待确认建议 + +Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施: + +```text +[PENDING] +日期: +建议: +原因: +收益: +风险: +迁移成本: +需要用户确认: +``` diff --git a/docs/12_CODEX_MASTER_PROMPT.md b/docs/12_CODEX_MASTER_PROMPT.md new file mode 100644 index 0000000..a1cc06b --- /dev/null +++ b/docs/12_CODEX_MASTER_PROMPT.md @@ -0,0 +1,153 @@ +# 12 可直接交给 Codex 的总指令 + +你现在是“乐活公益·暖心服务平台”的主开发工程师、架构维护者和测试负责人。 + +你的目标不是一次生成大量代码,而是在整个项目周期中持续、准确、可验证地完成系统。 + +## 一、第一原则 + +在写任何代码之前,必须完整阅读 `docs/` 中的项目文档,特别是: + +- 00_README_FIRST.md +- 01_PROJECT_CONSTITUTION.md +- 10_TASK_STATE.md +- 11_DECISIONS.md + +项目文档是当前事实来源。聊天上下文只能补充,不能覆盖已批准规则,除非用户明确要求修改。 + +## 二、记忆协议 + +每次任务开始: + +1. 读取项目开发宪法; +2. 读取当前任务状态; +3. 读取决策日志; +4. 检查 Git diff; +5. 确定任务属于哪个 Phase; +6. 输出本次实现范围,不扩大范围。 + +每次任务结束: + +1. 运行 lint; +2. 运行 typecheck; +3. 运行 unit tests; +4. 运行 integration tests; +5. 运行 build; +6. 需要时运行 E2E; +7. 更新 `10_TASK_STATE.md`; +8. 更新 `11_DECISIONS.md`(如有); +9. 更新 API/DB 文档; +10. 汇报“完成内容 / 未完成内容 / 测试结果 / 风险 / 下一步”。 + +如果上下文不足: +优先从仓库文档和代码中恢复,而不是凭空假设。 + +## 三、禁止行为 + +你绝对不能: + +- 擅自改产品方向; +- 删除业务需求来降低开发难度; +- 重写整个项目只因为你更喜欢另一套技术; +- 擅自换数据库; +- 擅自改认证方案; +- 跳过权限; +- 跳过 migration; +- 跳过测试; +- 用 mock 冒充生产功能; +- 写死生产密钥; +- 把敏感信息写日志; +- 把所有业务逻辑堆在 Controller; +- 让前端权限判断代替后端鉴权; +- 使用连续 ID 作为面向用户的资源标识; +- 对服务对象数据做无授权公开; +- 开发未批准的 AI 自动决策能力。 + +## 四、编码优先级 + +顺序永远是: + +1. 正确性 +2. 安全 +3. 数据一致性 +4. 用户可理解 +5. 可维护 +6. 可测试 +7. 性能 +8. 开发速度 +9. 技术“先进程度” + +## 五、实现策略 + +不要一次开发所有功能。 + +严格按 `10_TASK_STATE.md` Phase 执行。 + +一个 Phase 的 P0 测试不通过,不进入下一个 Phase。 + +## 六、发生冲突时 + +如果新需求与项目宪法冲突: + +- 不静默修改; +- 说明冲突点; +- 在 `11_DECISIONS.md` 建 PENDING 项; +- 等用户明确批准后再改变。 + +如果是不会改变边界的小实现细节,可自行采用最简单可靠方案,并记录重要决定。 + +## 七、安全要求 + +本项目处理弱势群体和未成年人相关信息。 + +任何时候都按: + +- 最小收集 +- 最小权限 +- 默认脱敏 +- 完整审计 +- 私有文件 +- 后端鉴权 +- 不信任客户端 +- 不信任用户输入 +- 不信任第三方回调 +- 不信任 AI 输出 + +执行。 + +## 八、完成标准 + +“页面能打开”不是完成。 +“接口返回 200”不是完成。 +“我写了代码但没运行测试”不是完成。 + +完成必须符合 `08_ENGINEERING_RULES.md` 的 Definition of Done 和 `09_TEST_ACCEPTANCE.md`。 + +## 九、第一次任务 + +请从 Phase 0 开始: + +1. 初始化 monorepo; +2. 创建 `apps/miniprogram`; +3. 创建 `apps/admin-web`; +4. 创建 `apps/api`; +5. 创建 `packages/contracts`; +6. 建立本地 Docker Compose:MySQL、Redis、MinIO; +7. 建立统一 lint/typecheck/test/build; +8. 创建 `/health`; +9. 创建 `.env.example`; +10. 创建根目录 `AGENTS.md`; +11. 创建 CI; +12. 运行全部命令并修复问题; +13. 更新 `docs/10_TASK_STATE.md`; +14. 不开发业务功能,等待 Phase 1。 + +完成后只汇报: + +- 新增/修改文件 +- 本地启动方式 +- 测试命令和结果 +- 已知问题 +- 下一步 Phase 1 计划 + +不要自行开始 Phase 1。 diff --git a/docs/13_DEPLOYMENT_CHINA.md b/docs/13_DEPLOYMENT_CHINA.md new file mode 100644 index 0000000..475d699 --- /dev/null +++ b/docs/13_DEPLOYMENT_CHINA.md @@ -0,0 +1,155 @@ +# 13 国内环境部署与运维 + +## 1. 目标 + +- 中国大陆访问稳定; +- 微信小程序合法域名可配置; +- TLS; +- 低维护成本; +- 可迁移; +- 可备份; +- 可回滚。 + +--- + +## 2. 推荐生产拓扑(初期) + +```text +微信小程序 + │ HTTPS + ▼ +Nginx / API Domain + │ + ▼ +NestJS API (Docker) + ├── MySQL + ├── Redis + └── Tencent COS + +管理后台 + │ HTTPS + └────────► Nginx / API +``` + +管理后台静态资源可由 Nginx 或对象存储/CDN 托管。 + +--- + +## 3. 云厂商 + +优先腾讯云并非代码强绑定要求,而是微信生态和国内运维便利性考虑。 + +代码必须通过 Adapter 保持: + +- COS 可替换 S3 兼容存储; +- Redis 可替换其他标准 Redis; +- MySQL 可迁移标准 MySQL; +- Nginx/Docker 不绑定专有 PaaS。 + +--- + +## 4. 国内依赖获取 + +开发机可根据实际环境配置: + +- npm 国内镜像; +- Docker 镜像加速; +- 操作系统软件源镜像。 + +但: + +- lockfile 必须提交; +- CI 至少验证标准 registry 可构建; +- 不依赖个人私服; +- 不在代码写死镜像地址。 + +--- + +## 5. 环境 + +### dev + +开发者本机 Docker Compose。 + +### test + +CI 自动化测试。 + +### staging + +与生产拓扑尽量一致,使用测试小程序/测试账号。 + +### prod + +真实用户。 + +严禁 dev 直连 prod 数据库。 + +--- + +## 6. 发布 + +建议: + +1. CI build; +2. 生成 immutable image tag; +3. staging deploy; +4. migration precheck; +5. smoke test; +6. production backup; +7. production deploy; +8. health check; +9. E2E smoke; +10. 观察; +11. 出现严重问题回滚应用版本。 + +数据库 migration 必须设计可前后兼容,避免应用回滚后数据库不兼容。 + +--- + +## 7. 监控 + +至少: + +- API availability +- 5xx rate +- P95 latency +- CPU/memory +- disk +- MySQL connection +- Redis connectivity +- job failures +- backup failures + +告警进入实际有人查看的渠道。 + +--- + +## 8. 容灾 + +初期无需昂贵双活,但必须: + +- 自动数据库备份; +- 异地/云端备份; +- IaC/部署脚本; +- Docker 镜像可重建; +- 文档化恢复; +- 定期演练。 + +--- + +## 9. 微信小程序上线准备 + +实际申请和上线阶段需要根据微信公众平台最新规则核对: + +- 小程序主体 +- 服务类目 +- 隐私保护指引 +- 用户信息/手机号等接口使用说明 +- request/download/upload 合法域名 +- HTTPS 证书 +- 备案/域名要求 +- 订阅消息模板 +- 审核材料 + +微信平台规则可能变化,因此上线前必须重新查官方文档,不得只依赖本文件。 diff --git a/docs/14_RELEASE_CHECKLIST.md b/docs/14_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..dfd8cef --- /dev/null +++ b/docs/14_RELEASE_CHECKLIST.md @@ -0,0 +1,68 @@ +# 14 发布检查清单 + +## 代码 + +- [ ] main branch clean +- [ ] lockfile committed +- [x] lint pass +- [x] typecheck pass +- [x] unit pass +- [x] integration pass +- [ ] E2E P0 pass +- [x] build pass + +## 数据库 + +- [x] migration reviewed +- [ ] backup done +- [x] rollback compatibility checked +- [x] indexes reviewed + +## 安全 + +- [x] secrets scan +- [x] dependency scan +- [x] auth test +- [x] IDOR test +- [x] export permission test +- [x] file upload test +- [ ] logs checked for PII + +## 隐私 + +- [ ] privacy policy version correct +- [x] consent flow correct +- [x] children/guardian flow checked +- [x] sensitive list fields masked +- [ ] retention configuration checked + +## 微信 + +- [ ] legal domains +- [ ] HTTPS +- [ ] privacy declarations +- [ ] API permissions +- [x] subscribe message handling +- [ ] real device test + +## 运维 + +- [x] health +- [x] monitor +- [x] alert +- [ ] backup +- [x] restore procedure +- [ ] rollback image available + +## UAT + +- [ ] 本人申请 +- [ ] 代申请 +- [ ] 受理 +- [ ] 建档 +- [ ] 派单 +- [ ] 服务 +- [ ] 回访 +- [ ] 继续帮扶 +- [ ] 志愿者 +- [ ] 活动报名 diff --git a/docs/15_FRONTEND_DESIGN_SPEC.md b/docs/15_FRONTEND_DESIGN_SPEC.md new file mode 100644 index 0000000..aa55996 --- /dev/null +++ b/docs/15_FRONTEND_DESIGN_SPEC.md @@ -0,0 +1,1670 @@ +# 15 前端界面设计与实现规范(Codex 执行版) + +> 项目:乐活公益·暖心服务平台 +> 版本:V1.0 +> 适用范围:微信小程序端 + Web 管理后台 +> 目标:Codex 必须依照本文档实现,不得自行改变既定视觉方向、信息架构和核心交互。 +> 关联文档:`01_PROJECT_CONSTITUTION.md`、`02_PRODUCT_PRD_V1.md`、`07_UI_UX.md`、`09_TEST_ACCEPTANCE.md` +> 视觉参考:`ui_reference.png` + +--- + +# 一、设计总则 + +## 1.1 产品气质 + +本项目面向公益服务对象、老年人、困境妇女儿童、残疾人、志愿者、公益工作人员和社会爱心人士。 + +视觉关键词: + +- 温暖 +- 可信 +- 清晰 +- 简洁 +- 亲和 +- 公益感 +- 服务感 +- 不商业化 +- 不娱乐化 +- 不科技炫技 + +界面必须让普通用户第一眼就知道: + +> “我在哪里、我能做什么、下一步点哪里。” + +--- + +## 1.2 设计风格 + +整体采用: + +- 浅色背景; +- 卡片式信息组织; +- 大留白; +- 大按钮; +- 圆角; +- 简洁图标; +- 真实公益服务照片; +- 橙色为主色; +- 绿色、蓝色、紫色作为业务辅助色。 + +禁止: + +- 大面积深色背景; +- 霓虹渐变; +- 玻璃拟态; +- 复杂动效; +- 过度阴影; +- 过多装饰插画; +- 信息密度过高; +- 商城化设计; +- 抽奖、积分、红包等强商业视觉; +- 短视频平台式沉浸内容流。 + +--- + +# 二、视觉设计系统 + +## 2.1 主色 + +推荐 CSS / Design Token: + +```ts +export const colors = { + primary: '#FF6B35', + primaryLight: '#FFF1EB', + primaryHover: '#FF7A4D', + primaryPressed: '#E95A28', + + success: '#52C41A', + info: '#1890FF', + warning: '#FAAD14', + purple: '#722ED1', + danger: '#E34D59', + + textPrimary: '#1F2329', + textSecondary: '#646A73', + textTertiary: '#8F959E', + textDisabled: '#B8BDC6', + + background: '#F5F5F5', + surface: '#FFFFFF', + border: '#E5E6EB', + divider: '#EBEDF0', +}; +``` + +### 使用规则 + +橙色: + +- 核心 CTA; +- “我需要帮助”; +- “提交申请”; +- “立即参与”; +- 强调数字。 + +绿色: + +- 已完成; +- 服务正常; +- 志愿服务; +- 成功状态。 + +蓝色: + +- 信息类; +- 进行中; +- 老年/残障等部分入口可辅助使用。 + +紫色: + +- 妇女儿童、心理关怀类辅助标签; +- 禁止大面积使用。 + +红色: + +- 错误、风险、失败; +- 不用于普通公益强调。 + +--- + +## 2.2 字体 + +优先使用系统字体。 + +微信小程序: + +```text +-apple-system +BlinkMacSystemFont +"Helvetica Neue" +"PingFang SC" +"Microsoft YaHei" +sans-serif +``` + +Web 后台: +同上。 + +--- + +## 2.3 字号 + +### 小程序 + +```text +页面大标题 24px / 600 +区块标题 20px / 600 +卡片标题 17px / 600 +正文 16px / 400 +辅助文字 14px / 400 +标签 12~13px +主要按钮 17px / 600 +``` + +核心原则: + +普通正文不得默认使用 12px。 + +适老场景: + +- 可提供“大字模式”二期; +- V1 默认已经采用偏大字号。 + +--- + +## 2.4 圆角 + +```text +小卡片:12px +大卡片:16px +按钮:12~24px +头像:50% +图片:12px +标签:6px +``` + +--- + +## 2.5 间距体系 + +采用 4 的倍数: + +```text +4 / 8 / 12 / 16 / 20 / 24 / 32 +``` + +推荐: + +```text +页面左右边距:16px +卡片内部:16px +区块之间:20~24px +列表项高度:56px 以上 +主要按钮高度:48~52px +``` + +--- + +## 2.6 阴影 + +仅轻阴影: + +```css +box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); +``` + +禁止重阴影。 + +--- + +# 三、图标规范 + +优先使用 TDesign 官方图标。 + +禁止: + +- Emoji 代替正式图标; +- 多套图标风格混用; +- 图标无文字解释地承担核心操作。 + +核心图标: + +```text +首页 +服务 +志愿者 +项目 +活动 +用户 +电话 +消息 +位置 +日历 +记录 +回访 +评价 +文件 +上传 +提醒 +公益爱心 +``` + +--- + +# 四、图片规范 + +公益项目展示使用真实、温暖、尊重被服务对象尊严的照片。 + +禁止: + +- 悲情营销; +- 刻意展示脆弱、哭泣、伤残特征; +- 暴露儿童身份; +- 暴露家庭住址; +- 未授权特写; +- 过度摆拍。 + +建议图片比例: + +```text +首页 Banner:16:7 +项目卡片:16:9 +活动卡片:16:9 +头像:1:1 +服务记录:4:3 +``` + +--- + +# 五、小程序底部导航 + +固定四项: + +```text +首页 +服务 +公益 +我的 +``` + +图标 + 中文。 + +默认: + +- 未选中:灰色; +- 选中:主色橙。 + +不要设计第五个 Tab。 + +--- + +# 六、小程序首页 + +## 6.1 页面目标 + +用户 3 秒内知道: + +1. 这里是乐活公益; +2. 可以申请帮助; +3. 可以帮家人申请; +4. 可以参与公益。 + +--- + +## 6.2 页面结构 + +```text +AppBar +品牌欢迎区 / Banner +核心入口区 +服务分类区 +服务进度入口 +公益项目 +公益活动 +加入公益 +Footer Safe Area +``` + +--- + +## 6.3 顶部 + +左: + +- 乐活公益 Logo +- “乐活公益” + +右: + +- 微信原生胶囊区域,不模拟。 + +--- + +## 6.4 Banner + +文案建议: + +```text +用爱连接 · 让爱传递 +乐活公益 · 助人为乐 · 服务社会 +``` + +Banner 不做自动轮播。 + +CTA 不超过 1 个。 + +--- + +## 6.5 核心入口 + +必须视觉突出: + +### 第一按钮 + +```text +我需要帮助 +``` + +### 第二按钮 + +```text +帮家人申请 +``` + +允许上下排列或左右双卡。 + +不得把这两个入口隐藏到二级菜单。 + +--- + +## 6.6 服务分类 + +四个核心: + +```text +关爱老人 +困境妇女 +儿童关爱 +残障帮扶 +``` + +补充: + +```text +志愿服务 +公益项目 +爱心活动 +更多服务 +``` + +每项: + +- 图标; +- 文字; +- 点击区域 >= 48px。 + +--- + +## 6.7 服务进度 + +若用户存在申请: + +显示: + +```text +我的服务 +服务编号:LHXXXXXXXX +当前状态:工作人员正在联系 +查看进度 > +``` + +若无: +不显示此卡片。 + +--- + +## 6.8 公益项目 + +标题: + +```text +公益项目 更多 > +``` + +项目卡片内容: + +- 图片 +- 项目名 +- 简介 +- 服务对象 +- 当前进展(可选) +- 查看详情 + +不要首页展示复杂捐赠进度。 + +--- + +# 七、服务申请页面 + +## 7.1 页面原则 + +不一次展示长表单。 + +采用步骤式: + +```text +步骤 1:需要什么帮助 +步骤 2:帮谁申请 +步骤 3:联系方式 +步骤 4:描述需求 +步骤 5:确认提交 +``` + +--- + +## 7.2 步骤一:服务类型 + +大卡片选择: + +```text +关爱老人 +困境妇女 +儿童关爱 +残障帮扶 +生活困难 +其他 +``` + +每张卡片包含: + +- 图标/图片; +- 名称; +- 一句话解释。 + +--- + +## 7.3 步骤二:申请关系 + +```text +为自己申请 +帮家人申请 +社区/机构代办 +志愿者代办 +其他 +``` + +--- + +## 7.4 步骤三:基本联系信息 + +字段: + +```text +服务对象姓名/称呼 +联系电话 +所在地区 +期望联系时间(可选) +``` + +精确地址不在第一步强制收集。 + +--- + +## 7.5 步骤四:需求描述 + +组件: + +- 多行文本框; +- 上传图片; +- 字数提示; +- 可选“是否需要上门”; +- 可选“行动是否不方便”。 + +文案: + +```text +请简单描述目前遇到的困难或希望得到的帮助 +``` + +--- + +## 7.6 步骤五:确认 + +必须显示: + +- 服务类型; +- 服务对象; +- 联系方式脱敏; +- 需求摘要; +- 隐私授权。 + +按钮: + +```text +提交申请 +``` + +--- + +## 7.7 提交成功 + +成功页: + +图标: +✅ + +标题: + +```text +申请已提交 +``` + +说明: + +```text +乐活公益工作人员将根据实际情况与您联系。 +``` + +显示: + +```text +服务编号 +提交时间 +当前状态 +``` + +按钮: + +```text +查看服务进度 +返回首页 +``` + +--- + +# 八、我的服务 / 进度页面 + +采用 Timeline。 + +状态: + +```text +已提交 +工作人员联系 +需求评估 +服务安排 +服务中 +服务完成 +回访 +``` + +当前状态高亮。 + +每个节点: + +- 时间; +- 状态; +- 简要说明。 + +不向用户展示内部技术状态码。 + +--- + +# 九、服务对象档案页面(后台为主) + +## 9.1 页面结构 + +```text +头像 / 姓名 / 标签 +基本资料 +联系方式 +服务需求 +历史服务 +回访记录 +附件 +操作 +``` + +--- + +## 9.2 顶部摘要 + +示例: + +```text +张奶奶 +女 / 78岁 +[关爱老人] [独居] [重点关爱] +``` + +右侧操作: + +```text +联系 +新建服务 +新增回访 +编辑资料 +``` + +--- + +## 9.3 敏感字段 + +默认脱敏。 + +点击“查看完整信息”: + +- 检查权限; +- 记录审计; +- 可提示“此次查看将被记录”。 + +--- + +# 十、服务记录页面 + +记录卡片: + +```text +上门探访 +2026-08-10 10:00 +服务人员:李志愿者 +服务时长:1.5小时 + +服务内容: +…… + +服务照片: +[图][图][图] + +服务对象反馈: +★★★★★ +``` + +工作人员可新增。 + +普通用户只能查看与自己相关、允许公开给本人/监护人的记录。 + +--- + +# 十一、反馈与评价 + +页面保持简单: + +```text +本次服务您满意吗? +★★★★★ + +是否还需要帮助? +○ 不需要 +○ 需要继续帮助 + +其他反馈 +[输入框] + +提交反馈 +``` + +如果选择“需要继续帮助”,提交后明确: + +```text +我们已记录您的需求,工作人员会继续跟进。 +``` + +--- + +# 十二、公益项目详情 + +结构: + +```text +封面 +项目名称 +项目标签 +项目简介 +项目目标 +服务内容 +项目进展 +项目动态 +参与方式 +``` + +底部固定 CTA: + +```text +我要参与 +``` + +如果没有公开募捐能力,不出现“立即捐款”。 + +--- + +# 十三、志愿者中心 + +## 13.1 顶部身份卡 + +```text +李志愿者 +已认证志愿者 + +累计服务 56小时 +参与活动 23次 +帮助人数 18人 +``` + +--- + +## 13.2 快捷入口 + +```text +我的报名 +服务中 +已完成 +服务时长 +``` + +--- + +## 13.3 功能 + +```text +服务记录 +培训学习 +证书/荣誉 +我的团队 +设置 +``` + +V1 中证书/荣誉可先预留,不强做。 + +--- + +# 十四、个人中心 + +顶部用户卡: + +```text +头像 +昵称 +手机号脱敏 +``` + +数据摘要: + +```text +我的关注 +我的收藏 +我的捐赠(若未来合规开放) +我的服务 +``` + +V1 建议优先: + +```text +我的服务申请 +我的活动 +我的志愿服务 +我的消息 +我的评价 +帮助与反馈 +关于乐活 +隐私设置 +设置 +``` + +--- + +# 十五、Web 管理后台整体布局 + +## 15.1 页面结构 + +桌面优先: + +```text +左侧导航 240px +顶部栏 56px +主内容区 +``` + +左侧导航深色或浅深蓝灰均可,但整体不应压迫。 + +--- + +## 15.2 导航 + +```text +工作台 +服务申请 +服务对象 +服务工单 +回访管理 +志愿者管理 +公益项目 +公益活动 +内容管理 +数据统计 +用户与权限 +系统设置 +``` + +审计日志只对高权限显示。 + +--- + +# 十六、后台工作台 + +## 16.1 顶部数据卡 + +最多 4~6 项: + +```text +今日新申请 +待联系 +服务中 +待回访 +重点关爱 +今日服务 +``` + +--- + +## 16.2 图表 + +V1 最多 2~3 张: + +```text +服务趋势 +服务类型分布 +本月任务完成情况 +``` + +不要堆 10 个图。 + +--- + +## 16.3 快捷入口 + +```text +新增服务对象 +新增工单 +志愿者招募 +发布公益活动 +``` + +--- + +# 十七、服务申请列表 + +## 17.1 筛选 + +```text +关键词 +服务类型 +状态 +负责人 +时间范围 +``` + +按钮: + +```text +查询 +重置 +``` + +--- + +## 17.2 表格列 + +```text +服务编号 +服务对象 +服务类型 +年龄/年龄段 +联系电话(脱敏) +状态 +提交时间 +负责人 +操作 +``` + +操作: + +```text +查看 +受理 +``` + +“编辑”不是所有状态都显示。 + +--- + +# 十八、服务对象列表 + +列: + +```text +姓名 +服务类别 +年龄 +联系电话 +重点关爱 +最近服务 +下次回访 +状态 +操作 +``` + +默认手机号脱敏。 + +--- + +# 十九、服务工单详情 + +必须采用状态进度条: + +```text +待指派 +已指派 +服务中 +待回访 +已完成 +``` + +内容分左右两栏: + +左: + +- 工单信息; +- 服务对象; +- 服务需求; +- 服务记录。 + +右: + +- 负责人; +- 协作人员; +- 时间; +- 操作日志; +- 后续计划。 + +--- + +# 二十、回访中心 + +按待办设计,不做普通信息表。 + +顶部: + +```text +今日待回访 +已逾期 +本周待回访 +重点关爱 +``` + +列表: + +```text +服务对象 +上次服务 +回访原因 +计划日期 +负责人 +状态 +``` + +明显突出逾期项。 + +--- + +# 二十一、志愿者管理 + +列表字段: + +```text +姓名 +地区 +技能 +服务方向 +累计时长 +审核状态 +最近参与 +操作 +``` + +支持: + +```text +查看 +审核 +暂停 +恢复 +``` + +--- + +# 二十二、公益项目后台 + +项目详情后台分: + +```text +基本信息 +服务对象 +项目工单 +活动 +成果数据 +内容动态 +``` + +V1 不做复杂甘特图。 + +--- + +# 二十三、活动管理 + +活动创建步骤: + +```text +基本信息 +招募设置 +时间地点 +内容说明 +发布确认 +``` + +报名列表: + +```text +姓名 +手机号 +志愿者状态 +报名时间 +审核 +签到 +服务时长 +``` + +--- + +# 二十四、内容管理 + +内容列表: + +```text +标题 +类型 +状态 +作者 +发布时间 +操作 +``` + +状态: + +```text +草稿 +待审核 +已发布 +已归档 +``` + +编辑器: + +- Markdown / 富文本二选一; +- 建议成熟富文本; +- 禁止直接注入 HTML。 + +--- + +# 二十五、数据统计页面 + +## 25.1 顶部 KPI + +```text +服务完成率 +用户满意度 +活跃志愿者 +回访完成率 +``` + +--- + +## 25.2 图表 + +```text +月度服务趋势 +服务类型分布 +项目服务量 +志愿服务时长 +``` + +所有指标必须能追溯到数据库真实数据。 + +--- + +# 二十六、响应式 + +## 小程序 + +覆盖常见微信手机尺寸。 + +最低关注: + +- iPhone SE 宽度; +- 常见 Android 360~430 CSS px。 + +禁止水平滚动。 + +## 后台 + +主目标: + +- 1366×768 +- 1440×900 +- 1920×1080 + +最低: + +- 1280px 宽正常使用。 + +小于 1024: + +- 导航折叠; +- 表格允许横向滚动; +- 不要求完整移动后台。 + +--- + +# 二十七、组件清单 + +Codex 必须优先抽取共享组件。 + +## 小程序 + +```text +AppHeader +HeroBanner +PrimaryActionCard +ServiceCategoryGrid +ProjectCard +ActivityCard +StatusTimeline +EmptyState +ErrorState +LoadingSkeleton +FormSection +PrivacyConsent +FileUploader +BeneficiarySummary +VolunteerProfileCard +BottomSafeArea +``` + +## 后台 + +```text +PageHeader +StatCard +FilterBar +StatusTag +SensitiveText +DataTable +EmptyState +ConfirmDialog +AuditNotice +BeneficiaryDrawer +ServiceTimeline +WorkOrderPanel +PermissionGuard +FilePreview +``` + +--- + +# 二十八、状态规范 + +所有页面至少实现: + +```text +loading +success +empty +error +no-permission +offline / retry +``` + +不允许只有 success。 + +--- + +# 二十九、Skeleton + +列表和首页采用 Skeleton。 + +不要用全屏 Spinner 阻断用户。 + +--- + +# 三十、Toast / Message + +成功: + +```text +提交成功 +保存成功 +操作已完成 +``` + +失败: + +```text +提交失败,请稍后重试 +``` + +有 requestId 时: + +```text +如问题持续,请联系客服并提供编号:XXXX +``` + +--- + +# 三十一、确认弹窗 + +只用于高风险操作: + +- 取消服务; +- 删除草稿; +- 归档; +- 移除志愿者; +- 改关键状态; +- 导出敏感信息。 + +普通保存不弹确认。 + +--- + +# 三十二、权限 UI + +前端可以隐藏无权限按钮,但: + +> 前端权限不是安全边界。 + +所有敏感操作后端必须再次授权。 + +如果接口 403: +显示: + +```text +您暂无权限执行此操作。 +``` + +不得显示技术错误。 + +--- + +# 三十三、数据脱敏展示 + +手机号: + +```text +138****8888 +``` + +地址: +普通列表: + +```text +丹阳市开发区 +``` + +详细地址仅授权用户查看。 + +儿童信息: +列表优先使用: + +```text +小明(化名) +``` + +具体规则由后台数据和权限控制。 + +--- + +# 三十四、可访问性与适老 + +必须: + +- 点击区域充足; +- 对比度足够; +- 不仅靠颜色区分状态; +- 图标+文字; +- 表单错误有文字; +- 输入焦点明显; +- 主要按钮不放得过近; +- 不使用晃动/闪烁。 + +--- + +# 三十五、前端工程结构 + +## 小程序 + +```text +apps/miniprogram/ +├─ miniprogram/ +│ ├─ pages/ +│ ├─ components/ +│ ├─ services/ +│ ├─ stores/ +│ ├─ utils/ +│ ├─ constants/ +│ ├─ styles/ +│ └─ assets/ +├─ project.config.json +└─ tsconfig.json +``` + +--- + +## 后台 + +```text +apps/admin-web/src/ +├─ api/ +├─ assets/ +├─ components/ +├─ composables/ +├─ constants/ +├─ layouts/ +├─ router/ +├─ stores/ +├─ styles/ +├─ types/ +├─ utils/ +└─ views/ +``` + +--- + +# 三十六、Design Token + +必须统一定义,而不是组件内散落。 + +建议: + +```ts +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +}; +``` + +颜色、字号、圆角也集中。 + +--- + +# 三十七、API 使用规范 + +前端禁止页面内直接 `wx.request` / `fetch`。 + +统一: + +```text +services/apiClient +``` + +统一处理: + +- baseURL; +- auth; +- requestId; +- timeout; +- 401; +- 403; +- 429; +- 5xx; +- 重试策略。 + +--- + +# 三十八、前端缓存 + +可缓存: + +- 用户基本资料; +- 公益项目公开列表; +- 服务分类; +- 表单草稿; +- UI 设置。 + +不得长期本地保存: + +- 完整服务对象敏感档案; +- 详细家庭困难; +- 儿童敏感信息; +- 后台敏感数据; +- 长期访问 Token。 + +--- + +# 三十九、小程序性能 + +要求: + +- 首页不要一次请求所有业务数据; +- 图片 lazy-load; +- 图片压缩; +- 分包; +- 首屏只加载必要资源; +- 避免超大组件库全量引入; +- 每个页面按需引用。 + +--- + +# 四十、管理后台性能 + +要求: + +- 路由懒加载; +- 列表分页; +- 筛选防抖; +- 图表按需加载; +- 不一次性加载全部历史服务; +- 大附件不 preload。 + +--- + +# 四十一、前端测试 + +## Unit + +测试: + +- 状态格式化; +- 权限组件; +- 表单校验; +- 脱敏函数; +- timeline mapping; +- API error mapping。 + +## Component + +关键组件: + +- ServiceCategoryGrid +- StatusTimeline +- SensitiveText +- FileUploader +- DataTable + +## E2E + +必须覆盖: + +```text +首页 → 我需要帮助 → 提交申请 +首页 → 帮家人申请 → 提交申请 +我的 → 我的服务 → 查看进度 +后台 → 申请 → 受理 → 创建工单 +后台 → 工单 → 服务记录 → 完成 +后台 → 回访 → 继续帮助 +志愿者 → 活动报名 +``` + +--- + +# 四十二、视觉验收 + +Codex 完成页面后必须对照 `ui_reference.png`。 + +重点不是逐像素复制,而是保证: + +- 色彩方向一致; +- 卡片风格一致; +- 信息密度一致; +- 主 CTA 一致; +- 圆角一致; +- 页面留白一致; +- 不出现复杂科技感; +- 不出现商业商城风。 + +--- + +# 四十三、页面完成定义 + +单页面只有满足以下条件才算完成: + +- UI 完成; +- loading 完成; +- empty 完成; +- error 完成; +- 权限状态完成; +- API 对接完成; +- 表单校验完成; +- 防重复提交完成; +- 小尺寸测试完成; +- 真实设备测试完成; +- 无 Console Error; +- 无 TypeScript Error; +- 单元/组件测试通过; +- 对照设计规范完成。 + +--- + +# 四十四、Codex 前端执行指令 + +每次前端任务开始: + +1. 阅读本文档; +2. 阅读 PRD; +3. 阅读 UI/UX 规范; +4. 检查 `ui_reference.png`; +5. 确认当前 Phase; +6. 只实现当前页面; +7. 不扩大功能。 + +每次页面完成后: + +1. 运行 lint; +2. typecheck; +3. component tests; +4. build; +5. 截图; +6. 对照设计图自检; +7. 更新 `10_TASK_STATE.md`; +8. 汇报差异。 + +--- + +# 四十五、V1 前端开发顺序 + +严格顺序: + +## Sprint UI-01 + +- Design Tokens +- 小程序导航 +- 小程序首页 +- 空状态/错误态/Skeleton +- 后台 Layout + +## Sprint UI-02 + +- 服务类型选择 +- 本人申请 +- 代申请 +- 提交成功 +- 我的申请 +- 服务进度 + +## Sprint UI-03 + +- 后台服务申请列表 +- 服务申请详情 +- 服务对象列表 +- 服务对象详情 + +## Sprint UI-04 + +- 工单列表 +- 工单详情 +- 服务记录 +- 回访中心 + +## Sprint UI-05 + +- 志愿者中心 +- 志愿者后台 +- 公益项目 +- 公益活动 + +## Sprint UI-06 + +- 内容管理 +- 数据统计 +- 权限页面 +- 系统设置 +- 全局视觉修正 +- 真机验收 + +未经批准,不要提前实现捐赠、AI、地图。 + +--- + +# 四十六、最终界面原则 + +前端实现结果必须使不同用户获得以下体验: + +### 对老年人 + +“按钮大、字清楚、步骤少,我知道怎么求助。” + +### 对家属 + +“我能方便地替父母或亲属申请。” + +### 对志愿者 + +“我知道哪里需要我、我参加了什么、服务了多久。” + +### 对工作人员 + +“每天打开后台,我就知道今天要处理什么。” + +### 对负责人 + +“项目做了多少服务、服务了谁、完成到什么程度,都能看清。” + +### 对社会公众 + +“我能看见乐活公益真正做了什么,也知道怎么参与。” + +这就是 V1 前端是否成功的最终判断标准。 diff --git a/docs/16_CODEX_FRONTEND_START_PROMPT.md b/docs/16_CODEX_FRONTEND_START_PROMPT.md new file mode 100644 index 0000000..1911a84 --- /dev/null +++ b/docs/16_CODEX_FRONTEND_START_PROMPT.md @@ -0,0 +1,52 @@ +# Codex 前端开发启动指令 + +你现在负责“乐活公益·暖心服务平台”的前端实现。 + +开始前必须阅读: + +- docs/00_README_FIRST.md +- docs/01_PROJECT_CONSTITUTION.md +- docs/02_PRODUCT_PRD_V1.md +- docs/07_UI_UX.md +- docs/10_TASK_STATE.md +- docs/11_DECISIONS.md +- docs/15_FRONTEND_DESIGN_SPEC.md + +并查看: + +- docs/ui_reference.png + +执行规则: + +1. 不得自行改变已确认视觉方向; +2. 不得把公益平台做成商城、资讯门户、社交平台或科技大屏; +3. 小程序优先普通群众和适老使用; +4. 后台优先工作人员高频任务; +5. 每个页面必须包含 loading / empty / error / permission 状态; +6. 前端只隐藏按钮不能代替后端鉴权; +7. 不得写死敏感数据; +8. 不得为了页面好看增加未经批准的业务字段; +9. 不得提前开发 AI、地图、捐赠交易能力; +10. 每完成一个 Sprint,运行 lint、typecheck、tests、build,并更新 docs/10_TASK_STATE.md。 + +第一次前端任务仅执行: + +Sprint UI-01: + +- 建立 Design Tokens; +- 建立小程序底部导航; +- 完成小程序首页; +- 完成 EmptyState / ErrorState / LoadingSkeleton; +- 完成后台基础 Layout; +- 使用 mock 数据仅做视觉开发时,必须明确标注 mock,并在接入真实 API 后删除; +- 完成后截图并与 ui_reference.png 对照; +- 不开始 UI-02。 + +完成后汇报: + +- 文件变化; +- 页面截图; +- 视觉差异; +- 测试结果; +- 已知问题; +- 下一步。 diff --git a/docs/17_PRODUCTION_READINESS_PLAN.md b/docs/17_PRODUCTION_READINESS_PLAN.md new file mode 100644 index 0000000..0f50350 --- /dev/null +++ b/docs/17_PRODUCTION_READINESS_PLAN.md @@ -0,0 +1,99 @@ +# 17 生产就绪差距与阶段计划 + +> 评估日期:2026-08-11 +> 事实来源:`01_PROJECT_CONSTITUTION.md`、`02_PRODUCT_PRD_V1.md`、`09_TEST_ACCEPTANCE.md`、`10_TASK_STATE.md`、`13_DEPLOYMENT_CHINA.md`、`14_RELEASE_CHECKLIST.md`。 + +## 1. 当前结论 + +Phase 0–7 的可自动化开发与本地生产工程验证已完成。当前仍不满足生产发布条件,剩余差距来自真实环境与人工审批: + +1. Phase 6 内容审核、站内消息和微信通知 Adapter 已完成,真实微信模板发送待平台配置; +2. Phase 7 统计、脱敏汇总导出、安全扫描、备份恢复演练、监控规则和可回滚部署配置已完成,真实 staging、UAT 和 production deployment 待执行; +3. 微信、域名备案、TLS、对象存储和运营合规材料需要人工账号与真实环境。 + +在上述工作完成前,只能视为可持续开发的 MVP,不得标记为可生产发布。 + +## 2. 分阶段交付计划 + +### 阶段 A:Phase 4 回访闭环(已完成,2026-08-11) + +- 服务完成与自动回访任务在同一事务中提交; +- 回访待办、今日/逾期/本周/重点关怀视图; +- 人工及重点关怀回访任务; +- 回访结果、完成历史和审计; +- `NEED_MORE_HELP` 原子创建关联后续申请,保留原历史; +- 完成 E2E-005、E2E-006 对应的集成验证。 + +退出条件:Phase 4 P0 测试、migration、文档和全仓质量门禁全部通过。 + +完成证据:Phase 4 migration 已部署到隔离测试库且无 schema drift;27 个单元测试、28 个真实 MySQL 集成测试、全仓类型检查和 production build 全部通过。 + +### 阶段 B:Phase 5 公益项目、活动与志愿者(已完成,2026-08-11) + +- 志愿者资料、申请审核、状态机和最小权限; +- 公益项目与活动 CRUD/发布状态机; +- 活动报名、防重复、容量与截止时间校验; +- 签到和服务时长事实记录; +- E2E-009 及角色越权验证。 + +退出条件:V1 的志愿参与链路可在 staging 使用生成数据完整跑通。 + +完成证据:Phase 5 migration 已部署到隔离测试库且无 schema drift;真实 MySQL 集成测试覆盖项目/活动/报名/审核/签到/服务分钟、容量上限、幂等重放和公开边界。真实微信、合法域名、对象存储与 staging 仍按外部配置项保留,未用测试值冒充完成。 + +### 阶段 C:Phase 6 内容与通知(已完成,2026-08-12) + +- 机构介绍、公益动态、草稿/审核/发布; +- 站内消息和投递记录; +- 微信订阅消息 Adapter、重试与失败补偿; +- 隐私政策、用户协议和授权页面接入可版本化内容; +- 外部通知失败不回滚主业务。 + +退出条件:公开内容审核、业务通知降级和隐私版本留痕通过测试。 + +完成证据:草稿公开隔离、审核状态机、服务故事隐私确认、危险内容拒绝、消息 IDOR 和订阅偏好已通过真实 MySQL 集成测试;真实微信发送保留为外部验收。 + +### 阶段 D:Phase 7 生产工程与发布(工程完成,外部发布待执行) + +- 基础统计与受控脱敏导出; +- readiness/liveness、结构化运行日志、5xx/P95/资源/数据库/任务/备份监控; +- SAST、依赖/密钥扫描、IDOR、CSRF、SQLi、XSS、上传绕过和暴力登录测试; +- MySQL 自动备份、对象存储保护、恢复脚本和实际恢复演练; +- Nginx、TLS、immutable image、staging/prod Compose 或等价部署清单; +- migration precheck、发布、冒烟、观察和应用回滚流程; +- P0 E2E、性能基线和多角色 UAT。 + +退出条件:`14_RELEASE_CHECKLIST.md` 全部勾选,Critical/High 为零,恢复演练和 staging 闭环通过。 + +当前证据:统计/导出、health/metrics、权限扫描、官方依赖审计、密钥扫描、CodeQL 工作流、备份/恢复脚本、Nginx、immutable image Compose、预检/冒烟和告警规则已完成;本地恢复演练源库与恢复库均为 42 张表。真实 staging、UAT、告警接收和生产发布尚未执行,因此本退出条件仍未满足。 + +## 3. 需要人工或外部环境配合 + +以下内容不能用测试值替代正式验收: + +- 微信小程序主体、类目、有效 AppID/AppSecret、接口权限和审核材料; +- 已备案域名、合法 request/upload/download HTTPS 域名和有效 TLS 证书; +- 生产 MySQL、Redis、私有对象存储 Bucket、最小权限密钥、CORS、生命周期/版本策略; +- 监控告警接收渠道和实际值班责任人; +- 数据保留期限、未成年人数据规则、隐私政策和用户协议的运营/法律确认; +- staging 测试账号、微信开发者工具、真机测试和非开发人员 UAT; +- 生产变更窗口、备份保留策略、回滚授权人与发布审批。 + +## 4. 发布阻断项 + +任一项未满足均阻断生产发布: + +- Phase 6–7 本地 P0 功能或测试回归失败; +- P0 E2E 未全通过; +- 存在 Critical/High 安全漏洞或未修复越权; +- 未完成真实备份恢复演练; +- 无 staging 全闭环记录或可回滚镜像; +- 隐私/监护授权、微信合规材料或合法域名未就绪; +- 监控、告警无人接收; +- 使用测试 Adapter、测试密钥或本地 HTTP 冒充生产能力。 + +## 5. 执行规则 + +- 严格按 Phase 顺序推进,一个阶段 P0 未通过不得进入下一阶段; +- 外部配置缺失时保留真实 Adapter 和明确不可用状态,不伪造联调成功; +- 每阶段结束更新 `10_TASK_STATE.md`,架构或产品决定追加到 `11_DECISIONS.md`; +- 每阶段均执行 format、lint、typecheck、unit、integration 和 build,生产阶段另执行安全、恢复、E2E 与性能门禁。 diff --git a/docs/AGENTS_TEMPLATE.md b/docs/AGENTS_TEMPLATE.md new file mode 100644 index 0000000..7d25462 --- /dev/null +++ b/docs/AGENTS_TEMPLATE.md @@ -0,0 +1,19 @@ +# AGENTS.md + +Before coding, read: + +1. docs/00_README_FIRST.md +2. docs/01_PROJECT_CONSTITUTION.md +3. docs/10_TASK_STATE.md +4. docs/11_DECISIONS.md +5. task-related docs + +Rules: + +- Do not change approved product scope or architecture without explicit approval. +- Do not bypass authorization, migrations, tests, or audit requirements. +- Do not log sensitive beneficiary data. +- Do not expose sequential internal IDs. +- Update docs/10_TASK_STATE.md after every task. +- Update docs/11_DECISIONS.md for architecture/product decisions. +- Run lint, typecheck, tests and build before marking work complete. diff --git a/docs/ui_reference.png b/docs/ui_reference.png new file mode 100644 index 0000000..0bd414a Binary files /dev/null and b/docs/ui_reference.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..49410a9 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,53 @@ +import eslint from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/miniprogram_npm/**', + '**/generated/prisma/**', + 'coverage/**', + 'eslint.config.mjs', + ], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + files: ['**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-misused-promises': [ + 'error', + { checksVoidReturn: { arguments: false } }, + ], + }, + }, + { + files: ['apps/miniprogram/**/*.ts'], + languageOptions: { + globals: { + App: 'readonly', + Page: 'readonly', + wx: 'readonly', + }, + }, + }, + { + files: ['**/*.config.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, +); diff --git a/infra/backup/backup-mysql.ps1 b/infra/backup/backup-mysql.ps1 new file mode 100644 index 0000000..58f0eb2 --- /dev/null +++ b/infra/backup/backup-mysql.ps1 @@ -0,0 +1,19 @@ +param([Parameter(Mandatory=$true)][string]$OutputDirectory,[int]$RetentionDays=30) +$ErrorActionPreference='Stop' +if(-not $env:DATABASE_URL){throw 'DATABASE_URL is required'} +$uri=[Uri]$env:DATABASE_URL +if($uri.Scheme -ne 'mysql'){throw 'DATABASE_URL must use mysql'} +$database=$uri.AbsolutePath.TrimStart('/') +if(-not $database){throw 'database name is required'} +$resolved=[IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $resolved | Out-Null +$stamp=(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') +$target=Join-Path $resolved "$database-$stamp.sql" +$credential=$uri.UserInfo.Split(':',2) +$env:MYSQL_PWD=[Uri]::UnescapeDataString($credential[1]) +& mysqldump --single-transaction --set-gtid-purged=OFF --host $uri.Host --port $uri.Port --user ([Uri]::UnescapeDataString($credential[0])) --result-file $target $database +if($LASTEXITCODE -ne 0){throw 'mysqldump failed'} +$hash=(Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLowerInvariant() +Set-Content -LiteralPath "$target.sha256" -Value "$hash $([IO.Path]::GetFileName($target))" +Get-ChildItem -LiteralPath $resolved -File | Where-Object {$_.LastWriteTimeUtc -lt (Get-Date).ToUniversalTime().AddDays(-$RetentionDays) -and ($_.Extension -eq '.sql' -or $_.Name.EndsWith('.sql.sha256'))} | Remove-Item -Force +Write-Output $target diff --git a/infra/backup/restore-mysql.ps1 b/infra/backup/restore-mysql.ps1 new file mode 100644 index 0000000..76f4216 --- /dev/null +++ b/infra/backup/restore-mysql.ps1 @@ -0,0 +1,15 @@ +param([Parameter(Mandatory=$true)][string]$BackupFile,[Parameter(Mandatory=$true)][string]$TargetDatabase) +$ErrorActionPreference='Stop' +if(-not $env:DATABASE_URL){throw 'DATABASE_URL is required'} +if($TargetDatabase -notmatch '^[A-Za-z0-9_]+$'){throw 'TargetDatabase contains invalid characters'} +$file=(Resolve-Path -LiteralPath $BackupFile).Path +$checksum="$file.sha256" +if(-not(Test-Path -LiteralPath $checksum)){throw 'checksum file is required'} +$expected=(Get-Content -LiteralPath $checksum).Split(' ')[0] +if((Get-FileHash -Algorithm SHA256 -LiteralPath $file).Hash.ToLowerInvariant() -ne $expected){throw 'backup checksum mismatch'} +$uri=[Uri]$env:DATABASE_URL;$credential=$uri.UserInfo.Split(':',2);$env:MYSQL_PWD=[Uri]::UnescapeDataString($credential[1]);$user=[Uri]::UnescapeDataString($credential[0]) +& mysql --host $uri.Host --port $uri.Port --user $user --execute "CREATE DATABASE IF NOT EXISTS ``$TargetDatabase`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +if($LASTEXITCODE -ne 0){throw 'target database creation failed'} +Get-Content -LiteralPath $file -Raw | & mysql --host $uri.Host --port $uri.Port --user $user $TargetDatabase +if($LASTEXITCODE -ne 0){throw 'restore failed'} +Write-Output "Restore completed into $TargetDatabase" diff --git a/infra/deploy/README.md b/infra/deploy/README.md new file mode 100644 index 0000000..4810f33 --- /dev/null +++ b/infra/deploy/README.md @@ -0,0 +1,20 @@ +# 发布与运维 + +生产发布只允许不可变镜像标签,顺序固定为:staging 预检 → migration 预检与备份 → 冒烟测试 → UAT 签字 → 生产备份 → 部署 → `/health/live`、`/health/ready` 和公开内容冒烟 → 观察 → 必要时回滚到上一镜像标签。禁止使用 `latest`。 + +## 外部准备 + +- 私有镜像仓库、TLS 证书和合法域名;生产 MySQL、Redis、私有对象存储。 +- 微信 AppID/AppSecret、六类订阅消息模板、合法 HTTPS 域名和真机授权。 +- 告警接收渠道、异地备份目标、隐私政策/用户协议/主体资质与 UAT/发布审批人。 + +## 执行 + +1. 设置 `IMAGE_TAG`、`REGISTRY_IMAGE` 及 `.env.example` 中所有生产必填环境变量。 +2. `./infra/deploy/preflight.ps1 staging`,部署 staging,执行 migration 和 `smoke.ps1`。 +3. 完成 `docs/14_RELEASE_CHECKLIST.md` 中 staging/UAT 项并留存证据。 +4. 使用 `infra/backup/backup-mysql.ps1` 备份并将 SQL、SHA-256 文件同步到异地加密存储。 +5. `./infra/deploy/preflight.ps1 production`,部署固定镜像;执行冒烟并观察 5xx、p95、数据库、磁盘、通知死信和备份告警。 +6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。 + +备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。 diff --git a/infra/deploy/compose.production.yaml b/infra/deploy/compose.production.yaml new file mode 100644 index 0000000..1ffeeca --- /dev/null +++ b/infra/deploy/compose.production.yaml @@ -0,0 +1,13 @@ +services: + api: + image: ${REGISTRY_IMAGE:?REGISTRY_IMAGE required}:${IMAGE_TAG:?IMAGE_TAG required} + restart: always + environment: + APP_ENV: production + ADMIN_COOKIE_SECURE: 'true' + NOTIFICATION_WORKER_ENABLED: 'true' + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid + security_opt: + - no-new-privileges:true diff --git a/infra/deploy/compose.staging.yaml b/infra/deploy/compose.staging.yaml new file mode 100644 index 0000000..7543ba5 --- /dev/null +++ b/infra/deploy/compose.staging.yaml @@ -0,0 +1,7 @@ +services: + api: + image: ${REGISTRY_IMAGE:?REGISTRY_IMAGE required}:${IMAGE_TAG:?IMAGE_TAG required} + restart: unless-stopped + environment: + APP_ENV: staging + ADMIN_COOKIE_SECURE: 'true' diff --git a/infra/deploy/preflight.ps1 b/infra/deploy/preflight.ps1 new file mode 100644 index 0000000..81aeab8 --- /dev/null +++ b/infra/deploy/preflight.ps1 @@ -0,0 +1,9 @@ +param([Parameter(Mandatory=$true)][ValidateSet('staging','production')][string]$Environment) +$ErrorActionPreference='Stop' +$required=@('IMAGE_TAG','DATABASE_URL','SESSION_TOKEN_PEPPER','AUDIT_IP_HASH_KEY','FIELD_ENCRYPTION_KEY','ADMIN_WEB_ORIGIN') +foreach($name in $required){if(-not [Environment]::GetEnvironmentVariable($name)){throw "Missing required environment variable: $name"}} +if($env:IMAGE_TAG -eq 'latest'){throw 'IMAGE_TAG must be immutable and cannot be latest'} +if($Environment -eq 'production' -and $env:ADMIN_COOKIE_SECURE -ne 'true'){throw 'production requires ADMIN_COOKIE_SECURE=true'} +docker compose -f compose.yaml -f "infra/deploy/compose.$Environment.yaml" config --quiet +if($LASTEXITCODE -ne 0){throw 'compose validation failed'} +Write-Output "Preflight passed for $Environment" diff --git a/infra/deploy/smoke.ps1 b/infra/deploy/smoke.ps1 new file mode 100644 index 0000000..abfa478 --- /dev/null +++ b/infra/deploy/smoke.ps1 @@ -0,0 +1,6 @@ +param([Parameter(Mandatory=$true)][string]$BaseUrl) +$ErrorActionPreference='Stop';$base=$BaseUrl.TrimEnd('/');$live=Invoke-RestMethod -Uri "$base/health/live" -TimeoutSec 10;$ready=Invoke-RestMethod -Uri "$base/health/ready" -TimeoutSec 10 +if($live.status -ne 'ok' -or $ready.status -ne 'ready'){throw 'health smoke test failed'} +$content=Invoke-RestMethod -Uri "$base/api/v1/content?page=1&pageSize=1" -TimeoutSec 10 +if($content.success -ne $true){throw 'public content smoke test failed'} +Write-Output 'Smoke tests passed' diff --git a/infra/docker/README.md b/infra/docker/README.md new file mode 100644 index 0000000..8ac9e6e --- /dev/null +++ b/infra/docker/README.md @@ -0,0 +1,22 @@ +# 本地基础设施 + +根目录 `compose.yaml` 启动 MySQL 8、Redis 7 与 MinIO。它们仅用于开发环境,不保存不可恢复的生产业务事实。 + +```bash +cp .env.example .env +docker compose up -d +docker compose ps +``` + +默认端口:MySQL `3306`、Redis `6379`、MinIO API `9000`、MinIO Console `9001`。本地密码必须在 `.env` 中修改;`.env` 禁止提交。 + +认证、权限、服务申请和工单数据库集成测试使用隔离的 MySQL(端口 `3307`、数据库 `lehuo_test`): + +```bash +docker compose --profile test up -d mysql-test +pnpm test:integration +``` + +Phase 3 附件使用私有 MinIO Bucket。开发者需在 MinIO Console 创建 `.env` 中的 `OBJECT_STORAGE_BUCKET`(默认 `lehuo-private`),并将 `OBJECT_STORAGE_ACCESS_KEY/SECRET_KEY` 配置为具备该私有 Bucket 最小读写权限的凭据;管理端直传还需为已批准的管理端 Origin 配置仅允许 `PUT` 和所需响应头的 Bucket CORS。可在纯本地环境临时使用 MinIO 开发账号,但生产必须使用独立最小权限密钥。未配置凭据时附件接口会明确返回存储服务不可用。 + +停止服务使用 `docker compose down`。不要在有需保留的本地数据时添加 `--volumes`。 diff --git a/infra/monitoring/alert-rules.yml b/infra/monitoring/alert-rules.yml new file mode 100644 index 0000000..d368ced --- /dev/null +++ b/infra/monitoring/alert-rules.yml @@ -0,0 +1,23 @@ +groups: + - name: lehuo-api + rules: + - alert: ApiUnavailable + expr: probe_success{job="lehuo-api"} == 0 + for: 2m + labels: { severity: critical } + annotations: { summary: '乐活公益 API 不可用' } + - alert: ApiHighLatency + expr: lehuo_api_p95_milliseconds > 1000 + for: 10m + labels: { severity: warning } + annotations: { summary: 'API P95 超过 1 秒' } + - alert: NotificationDeadLetters + expr: lehuo_notification_dead_total > 0 + for: 5m + labels: { severity: warning } + annotations: { summary: '微信通知存在死信' } + - alert: BackupMissing + expr: time() - lehuo_backup_last_success_timestamp > 93600 + for: 10m + labels: { severity: critical } + annotations: { summary: '超过 26 小时无成功数据库备份' } diff --git a/infra/nginx/lehuo.conf b/infra/nginx/lehuo.conf new file mode 100644 index 0000000..cb103a4 --- /dev/null +++ b/infra/nginx/lehuo.conf @@ -0,0 +1,10 @@ +limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s; +server { listen 80; server_name _; return 301 https://$host$request_uri; } +server { + listen 443 ssl http2; server_name ${PUBLIC_HOST}; + ssl_certificate /etc/nginx/tls/fullchain.pem; ssl_certificate_key /etc/nginx/tls/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; + client_max_body_size 12m; + location / { limit_req zone=api_per_ip burst=30 nodelay; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://api:3000; } +} diff --git a/infra/security/permission-scan.ps1 b/infra/security/permission-scan.ps1 new file mode 100644 index 0000000..21d28da --- /dev/null +++ b/infra/security/permission-scan.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference='Stop' +$controllers=Get-ChildItem -Path apps/api/src -Recurse -Filter '*.controller.ts' +$findings=@() +foreach($file in $controllers){$text=Get-Content -LiteralPath $file.FullName -Raw;if($text -match "Controller\('api/v1/admin" -and $text -notmatch 'RequireSessionChannel'){ $findings+="$($file.FullName): admin controller lacks session-channel guard" };if($text -match "Controller\('api/v1/me" -and $text -notmatch 'RequireSessionChannel'){ $findings+="$($file.FullName): me controller lacks session-channel guard" }} +if($findings.Count){$findings|ForEach-Object{Write-Error $_};exit 1} +Write-Output "Permission scan passed for $($controllers.Count) controllers" diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d26cb5 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "lehuo-public-service", + "version": "0.1.0", + "private": true, + "description": "乐活公益·暖心服务平台 V1.0 monorepo", + "license": "UNLICENSED", + "packageManager": "pnpm@11.13.1", + "engines": { + "node": ">=24 <25", + "pnpm": ">=11 <12" + }, + "scripts": { + "build": "pnpm -r --if-present build", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint . --max-warnings=0", + "test": "pnpm test:unit && pnpm test:integration", + "test:integration": "pnpm --filter @lehuo/api test:integration", + "test:unit": "pnpm -r --if-present test:unit", + "typecheck": "pnpm -r --if-present typecheck" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "eslint": "^9.33.0", + "globals": "^16.3.0", + "prettier": "^3.6.2", + "typescript": "^5.9.2", + "typescript-eslint": "^8.39.1" + } +} diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..6562d11 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,3 @@ +# @lehuo/contracts + +API DTO、枚举和跨端共享类型的单一事实来源。业务枚举与接口契约必须在此维护,禁止前后端分别复制。 diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..3e264d2 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lehuo/contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test:unit": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts new file mode 100644 index 0000000..242172f --- /dev/null +++ b/packages/contracts/src/api.ts @@ -0,0 +1,49 @@ +export const API_ERROR_CODES = { + AUTH_REQUIRED: 'AUTH_REQUIRED', + AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED', + FORBIDDEN: 'FORBIDDEN', + VALIDATION_ERROR: 'VALIDATION_ERROR', + INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', + ACCOUNT_LOCKED: 'ACCOUNT_LOCKED', + ACCOUNT_DISABLED: 'ACCOUNT_DISABLED', + WECHAT_LOGIN_FAILED: 'WECHAT_LOGIN_FAILED', + RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND', + APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND', + BENEFICIARY_NOT_FOUND: 'BENEFICIARY_NOT_FOUND', + CASE_NOT_FOUND: 'CASE_NOT_FOUND', + SERVICE_RECORD_NOT_FOUND: 'SERVICE_RECORD_NOT_FOUND', + FILE_NOT_FOUND: 'FILE_NOT_FOUND', + FOLLOW_UP_NOT_FOUND: 'FOLLOW_UP_NOT_FOUND', + VOLUNTEER_NOT_FOUND: 'VOLUNTEER_NOT_FOUND', + VOLUNTEER_NOT_APPROVED: 'VOLUNTEER_NOT_APPROVED', + PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + ACTIVITY_NOT_FOUND: 'ACTIVITY_NOT_FOUND', + REGISTRATION_NOT_FOUND: 'REGISTRATION_NOT_FOUND', + ACTIVITY_FULL: 'ACTIVITY_FULL', + REGISTRATION_CLOSED: 'REGISTRATION_CLOSED', + RESOURCE_CONFLICT: 'RESOURCE_CONFLICT', + UPLOAD_NOT_READY: 'UPLOAD_NOT_READY', + STORAGE_UNAVAILABLE: 'STORAGE_UNAVAILABLE', + DUPLICATE_REQUEST: 'DUPLICATE_REQUEST', + INVALID_STATE_TRANSITION: 'INVALID_STATE_TRANSITION', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES]; + +export interface ApiSuccessResponse { + success: true; + data: T; + requestId: string; +} + +export interface ApiErrorResponse { + success: false; + error: { + code: ApiErrorCode; + message: string; + details?: Record; + }; + requestId: string; +} diff --git a/packages/contracts/src/auth.spec.ts b/packages/contracts/src/auth.spec.ts new file mode 100644 index 0000000..a253cbb --- /dev/null +++ b/packages/contracts/src/auth.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { API_ERROR_CODES, PERMISSION_CODES, ROLE_CODES } from './index.js'; + +describe('authentication contracts', () => { + it('keeps approved role and permission codes centralized', () => { + expect(ROLE_CODES).toContain('VOLUNTEER'); + expect(ROLE_CODES).toContain('SUPER_ADMIN'); + expect(PERMISSION_CODES.ADMIN_ACCESS).toBe('admin:access'); + }); + + it('uses stable public error codes', () => { + expect(API_ERROR_CODES.AUTH_REQUIRED).toBe('AUTH_REQUIRED'); + expect(API_ERROR_CODES.INVALID_CREDENTIALS).toBe('INVALID_CREDENTIALS'); + }); +}); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts new file mode 100644 index 0000000..3a40d9b --- /dev/null +++ b/packages/contracts/src/auth.ts @@ -0,0 +1,102 @@ +export const USER_STATUSES = ['ACTIVE', 'SUSPENDED', 'DISABLED'] as const; +export type UserStatus = (typeof USER_STATUSES)[number]; + +export const USER_TYPES = ['PUBLIC', 'STAFF'] as const; +export type UserType = (typeof USER_TYPES)[number]; + +export const ROLE_CODES = [ + 'VOLUNTEER', + 'STAFF', + 'PROJECT_MANAGER', + 'ADMIN', + 'SUPER_ADMIN', +] as const; +export type RoleCode = (typeof ROLE_CODES)[number]; + +export const PERMISSION_CODES = { + ADMIN_ACCESS: 'admin:access', + USERS_READ: 'users:read', + USERS_MANAGE: 'users:manage', + ROLES_MANAGE: 'roles:manage', + AUDIT_READ: 'audit:read', + SYSTEM_MANAGE: 'system:manage', + SENSITIVE_READ: 'sensitive:read', + SENSITIVE_EXPORT: 'sensitive:export', + RESOURCES_ACCESS_ALL: 'resources:access_all', + APPLICATIONS_READ: 'applications:read', + APPLICATIONS_MANAGE: 'applications:manage', + BENEFICIARIES_READ: 'beneficiaries:read', + BENEFICIARIES_MANAGE: 'beneficiaries:manage', + CASES_READ: 'cases:read', + CASES_MANAGE: 'cases:manage', + CASES_ASSIGN: 'cases:assign', + ASSIGNED_CASES_READ: 'assigned_cases:read', + SERVICE_RECORDS_WRITE: 'service_records:write', + FILES_WRITE: 'files:write', + FOLLOW_UPS_READ: 'follow_ups:read', + FOLLOW_UPS_MANAGE: 'follow_ups:manage', + FOLLOW_UPS_COMPLETE: 'follow_ups:complete', + VOLUNTEERS_READ: 'volunteers:read', + VOLUNTEERS_REVIEW: 'volunteers:review', + PROJECTS_READ: 'projects:read', + PROJECTS_MANAGE: 'projects:manage', + PROJECTS_PUBLISH: 'projects:publish', + ACTIVITIES_READ: 'activities:read', + ACTIVITIES_MANAGE: 'activities:manage', + ACTIVITIES_PUBLISH: 'activities:publish', + REGISTRATIONS_MANAGE: 'registrations:manage', + CONTENT_READ: 'content:read', + CONTENT_MANAGE: 'content:manage', + CONTENT_REVIEW: 'content:review', + NOTIFICATIONS_MANAGE: 'notifications:manage', + STATISTICS_READ: 'statistics:read', + EXPORTS_CREATE: 'exports:create', +} as const; +export type PermissionCode = (typeof PERMISSION_CODES)[keyof typeof PERMISSION_CODES]; + +export interface AuthUser { + publicId: string; + status: UserStatus; + userType: UserType; + displayName: string | null; + roles: RoleCode[]; + permissions: PermissionCode[]; +} + +export interface AdminLoginRequest { + username: string; + password: string; +} + +export interface AdminLoginResponse { + user: AuthUser; + expiresAt: string; +} + +export interface WechatLoginRequest { + code: string; +} + +export interface WechatLoginResponse { + accessToken: string; + tokenType: 'Bearer'; + expiresAt: string; + user: AuthUser; +} + +export interface CurrentUserResponse { + user: AuthUser; +} + +export interface LogoutResponse { + loggedOut: true; +} + +export interface UpdateUserRolesRequest { + roles: RoleCode[]; +} + +export interface UpdateUserRolesResponse { + userPublicId: string; + roles: RoleCode[]; +} diff --git a/packages/contracts/src/case-management.ts b/packages/contracts/src/case-management.ts new file mode 100644 index 0000000..5501553 --- /dev/null +++ b/packages/contracts/src/case-management.ts @@ -0,0 +1,308 @@ +import type { ServiceApplicationStatus, ServiceCategory } from './service-applications.js'; + +export const BENEFICIARY_GENDERS = ['FEMALE', 'MALE', 'OTHER', 'UNDISCLOSED'] as const; +export type BeneficiaryGender = (typeof BENEFICIARY_GENDERS)[number]; + +export const BENEFICIARY_STATUSES = ['ACTIVE', 'INACTIVE', 'ARCHIVED'] as const; +export type BeneficiaryStatus = (typeof BENEFICIARY_STATUSES)[number]; + +export const SERVICE_CASE_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const; +export type ServiceCasePriority = (typeof SERVICE_CASE_PRIORITIES)[number]; + +export const SERVICE_CASE_STATUSES = [ + 'ASSIGNED', + 'IN_SERVICE', + 'SERVICE_COMPLETED', + 'SUSPENDED', + 'CANCELLED', +] as const; +export type ServiceCaseStatus = (typeof SERVICE_CASE_STATUSES)[number]; + +export const CASE_ASSIGNEE_TYPES = ['STAFF', 'VOLUNTEER'] as const; +export type CaseAssigneeType = (typeof CASE_ASSIGNEE_TYPES)[number]; + +export const SERVICE_RECORD_TYPES = [ + 'HOME_VISIT', + 'PHONE_CONTACT', + 'ESCORT', + 'MATERIAL_SUPPORT', + 'ACTIVITY', + 'OTHER', +] as const; +export type ServiceRecordType = (typeof SERVICE_RECORD_TYPES)[number]; + +export const BENEFICIARY_CONFIRM_STATUSES = [ + 'NOT_REQUESTED', + 'CONFIRMED', + 'PARTIALLY_CONFIRMED', + 'DISPUTED', +] as const; +export type BeneficiaryConfirmStatus = (typeof BENEFICIARY_CONFIRM_STATUSES)[number]; + +export const SERVICE_CASE_STATUS_LABELS: Record = { + ASSIGNED: '已指派', + IN_SERVICE: '服务中', + SERVICE_COMPLETED: '服务已完成', + SUSPENDED: '服务暂缓', + CANCELLED: '已取消', +}; + +export const SERVICE_CASE_PRIORITY_LABELS: Record = { + LOW: '低', + NORMAL: '一般', + HIGH: '较高', + URGENT: '紧急', +}; + +export const SERVICE_RECORD_TYPE_LABELS: Record = { + HOME_VISIT: '上门探访', + PHONE_CONTACT: '电话关怀', + ESCORT: '陪同协助', + MATERIAL_SUPPORT: '物资支持', + ACTIVITY: '公益活动', + OTHER: '其他服务', +}; + +export interface BeneficiaryContactInput { + name: string; + relation: string; + mobile: string; + isGuardian: boolean; + isEmergencyContact: boolean; +} + +export interface NewBeneficiaryInput { + displayName: string; + gender?: BeneficiaryGender; + birthYear?: number; + mobile?: string; + regionCode: string; + addressDetail?: string; + keyCareFlag: boolean; + isMinor: boolean; + notes?: string; + contacts: BeneficiaryContactInput[]; + tagCodes: string[]; + guardianConsentConfirmed?: true; +} + +export interface AcceptServiceApplicationRequest { + clientRequestId: string; + beneficiary: + | { mode: 'CREATE'; profile: NewBeneficiaryInput } + | { + mode: 'LINK_EXISTING'; + beneficiaryPublicId: string; + manualMatchConfirmed: true; + }; + ownerStaffPublicId: string; + collaboratorUserPublicIds: string[]; + priority: ServiceCasePriority; + plannedStartAt?: string; + plannedEndAt?: string; + serviceLocation?: string; + summary: string; +} + +export interface TransitionServiceApplicationRequest { + targetStatus: Extract; + reasonCode: string; +} + +export interface BeneficiaryContactDetail { + publicId: string; + name: string; + relation: string; + mobile: string; + mobileMasked: string; + isGuardian: boolean; + isEmergencyContact: boolean; + hasConsentRecord: boolean; +} + +export interface BeneficiaryTagOption { + code: string; + name: string; + description: string | null; +} + +export interface BeneficiaryListItem { + publicId: string; + displayName: string; + gender: BeneficiaryGender | null; + birthYear: number | null; + mobileMasked: string | null; + regionCode: string; + primaryCategory: ServiceCategory; + keyCareFlag: boolean; + isMinor: boolean; + status: BeneficiaryStatus; + tags: BeneficiaryTagOption[]; + createdAt: string; +} + +export interface BeneficiaryDetail extends BeneficiaryListItem { + mobile: string | null; + addressDetail: string | null; + notes: string | null; + contacts: BeneficiaryContactDetail[]; + serviceCaseCount: number; +} + +export interface PaginatedBeneficiariesResponse { + items: BeneficiaryListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface BeneficiaryDetailResponse { + beneficiary: BeneficiaryDetail; +} + +export interface UpdateBeneficiaryRequest extends NewBeneficiaryInput { + status: BeneficiaryStatus; +} + +export interface AssigneeOption { + userPublicId: string; + displayName: string; + type: CaseAssigneeType; + department: string | null; +} + +export interface CaseAssigneeItem extends AssigneeOption { + role: 'OWNER' | 'COLLABORATOR'; + assignedAt: string; +} + +export interface ServiceCaseHistoryItem { + status: ServiceCaseStatus; + statusLabel: string; + reasonLabel: string; + createdAt: string; +} + +export interface ServiceRecordAttachment { + publicId: string; + filename: string; + mimeType: string; + size: number; +} + +export interface ServiceRecordItem { + publicId: string; + serviceStartAt: string; + serviceEndAt: string | null; + serviceType: ServiceRecordType; + serviceTypeLabel: string; + content: string; + result: string; + followUpSuggestion: string | null; + beneficiaryConfirmStatus: BeneficiaryConfirmStatus; + createdByDisplayName: string; + revision: number; + attachments: ServiceRecordAttachment[]; + createdAt: string; + updatedAt: string; +} + +export interface ServiceCaseListItem { + publicId: string; + caseNo: string; + applicationPublicId: string; + applicationNo: string; + beneficiaryPublicId: string; + beneficiaryDisplayName: string; + category: ServiceCategory; + priority: ServiceCasePriority; + priorityLabel: string; + status: ServiceCaseStatus; + statusLabel: string; + ownerDisplayName: string; + plannedStartAt: string | null; + createdAt: string; +} + +export interface ServiceCaseDetail extends ServiceCaseListItem { + regionCode: string; + beneficiaryMobile: string | null; + beneficiaryMobileMasked: string | null; + serviceLocation: string | null; + summary: string; + resultSummary: string | null; + followUpPlan: string | null; + completedAt: string | null; + assignees: CaseAssigneeItem[]; + history: ServiceCaseHistoryItem[]; + records: ServiceRecordItem[]; +} + +export interface PaginatedServiceCasesResponse { + items: ServiceCaseListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface ServiceCaseDetailResponse { + serviceCase: ServiceCaseDetail; +} + +export interface AcceptServiceApplicationResponse { + serviceCase: ServiceCaseDetail; + idempotentReplay: boolean; +} + +export interface ReplaceCaseAssigneesRequest { + collaboratorUserPublicIds: string[]; +} + +export interface CreateServiceRecordRequest { + clientRequestId: string; + serviceStartAt: string; + serviceEndAt?: string; + serviceType: ServiceRecordType; + content: string; + result: string; + followUpSuggestion?: string; + beneficiaryConfirmStatus: BeneficiaryConfirmStatus; + attachmentPublicIds: string[]; +} + +export interface UpdateServiceRecordRequest extends Omit< + CreateServiceRecordRequest, + 'clientRequestId' +> { + expectedRevision: number; +} + +export interface ServiceRecordResponse { + record: ServiceRecordItem; + idempotentReplay?: boolean; +} + +export interface CreateUploadIntentRequest { + clientRequestId: string; + filename: string; + mimeType: 'image/jpeg' | 'image/png' | 'image/webp'; + size: number; + sha256?: string; +} + +export interface CreateUploadIntentResponse { + file: ServiceRecordAttachment; + uploadUrl: string; + uploadHeaders: Record; + expiresAt: string; + idempotentReplay: boolean; +} + +export interface ConfirmUploadResponse { + file: ServiceRecordAttachment; +} + +export interface DownloadUrlResponse { + downloadUrl: string; + expiresAt: string; +} diff --git a/packages/contracts/src/content-notifications.ts b/packages/contracts/src/content-notifications.ts new file mode 100644 index 0000000..2673cd6 --- /dev/null +++ b/packages/contracts/src/content-notifications.ts @@ -0,0 +1,143 @@ +export const CONTENT_POST_TYPES = [ + 'ORGANIZATION_INTRO', + 'PUBLIC_WELFARE_NEWS', + 'SERVICE_STORY', + 'NOTICE', + 'FAQ', +] as const; +export type ContentPostType = (typeof CONTENT_POST_TYPES)[number]; +export const CONTENT_POST_TYPE_LABELS: Record = { + ORGANIZATION_INTRO: '机构介绍', + PUBLIC_WELFARE_NEWS: '公益动态', + SERVICE_STORY: '服务故事', + NOTICE: '通知公告', + FAQ: '常见问题', +}; +export const CONTENT_POST_STATUSES = ['DRAFT', 'PENDING_REVIEW', 'PUBLISHED', 'ARCHIVED'] as const; +export type ContentPostStatus = (typeof CONTENT_POST_STATUSES)[number]; +export const CONTENT_POST_STATUS_LABELS: Record = { + DRAFT: '草稿', + PENDING_REVIEW: '待审核', + PUBLISHED: '已发布', + ARCHIVED: '已归档', +}; +export interface ContentPostListItem { + publicId: string; + type: ContentPostType; + title: string; + summary: string; + status: ContentPostStatus; + revision: number; + publishedAt: string | null; + updatedAt: string; +} +export interface ContentStatusHistoryItem { + fromStatus: ContentPostStatus | null; + toStatus: ContentPostStatus; + reasonCode: string; + note: string | null; + createdAt: string; +} +export interface ContentPostDetail extends ContentPostListItem { + content: string; + coverFilePublicId: string | null; + privacyReviewedAt: string | null; + reviewedAt: string | null; + history: ContentStatusHistoryItem[]; +} +export interface PaginatedContentPostsResponse { + items: ContentPostListItem[]; + page: number; + pageSize: number; + total: number; +} +export interface ContentPostDetailResponse { + post: ContentPostDetail; + idempotentReplay?: boolean; +} +export interface CreateContentPostRequest { + clientRequestId: string; + type: ContentPostType; + title: string; + summary: string; + content: string; + coverFilePublicId?: string; +} +export interface UpdateContentPostRequest extends Omit< + CreateContentPostRequest, + 'clientRequestId' +> { + expectedRevision: number; +} +export interface TransitionContentPostRequest { + clientRequestId: string; + targetStatus: ContentPostStatus; + note: string; + privacyReviewed?: true; +} + +export const NOTIFICATION_TYPES = [ + 'APPLICATION_SUBMITTED', + 'APPLICATION_ACCEPTED', + 'SERVICE_ARRANGED', + 'SERVICE_COMPLETED', + 'FOLLOW_UP_PENDING', + 'ACTIVITY_REGISTRATION_RESULT', +] as const; +export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; +export const NOTIFICATION_TYPE_LABELS: Record = { + APPLICATION_SUBMITTED: '申请已提交', + APPLICATION_ACCEPTED: '申请已受理', + SERVICE_ARRANGED: '服务已安排', + SERVICE_COMPLETED: '服务已完成', + FOLLOW_UP_PENDING: '待回访', + ACTIVITY_REGISTRATION_RESULT: '活动报名结果', +}; +export interface NotificationItem { + publicId: string; + type: NotificationType; + title: string; + body: string; + resourceType: string | null; + resourcePublicId: string | null; + readAt: string | null; + createdAt: string; +} +export interface PaginatedNotificationsResponse { + items: NotificationItem[]; + page: number; + pageSize: number; + total: number; + unreadCount: number; +} +export interface NotificationPreferenceItem { + type: NotificationType; + inAppEnabled: boolean; + wechatEnabled: boolean; + authorizedAt: string | null; +} +export interface NotificationPreferencesResponse { + items: NotificationPreferenceItem[]; + wechatAdapterConfigured: boolean; +} +export interface UpdateNotificationPreferencesRequest { + items: Array<{ type: NotificationType; inAppEnabled: boolean; wechatEnabled: boolean }>; +} +export interface NotificationDeliveryItem { + publicId: string; + notificationPublicId: string; + type: NotificationType; + channel: 'WECHAT_SUBSCRIPTION'; + status: 'PENDING' | 'PROCESSING' | 'SENT' | 'FAILED' | 'DEAD'; + attemptCount: number; + nextAttemptAt: string; + lastAttemptAt: string | null; + errorCode: string | null; + createdAt: string; +} +export interface PaginatedNotificationDeliveriesResponse { + items: NotificationDeliveryItem[]; + page: number; + pageSize: number; + total: number; +} diff --git a/packages/contracts/src/follow-ups.spec.ts b/packages/contracts/src/follow-ups.spec.ts new file mode 100644 index 0000000..86e6da8 --- /dev/null +++ b/packages/contracts/src/follow-ups.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { + FOLLOW_UP_RESULT_CODES, + FOLLOW_UP_RESULT_LABELS, + FOLLOW_UP_STATUS_LABELS, + FOLLOW_UP_TYPES, + FOLLOW_UP_TYPE_LABELS, +} from './follow-ups.js'; + +describe('follow-up contracts', () => { + it('keeps every persisted enum value mapped to a user-facing label', () => { + expect(Object.keys(FOLLOW_UP_TYPE_LABELS).sort()).toEqual([...FOLLOW_UP_TYPES].sort()); + expect(Object.keys(FOLLOW_UP_RESULT_LABELS).sort()).toEqual([...FOLLOW_UP_RESULT_CODES].sort()); + expect(FOLLOW_UP_STATUS_LABELS.PENDING).toBe('待回访'); + }); +}); diff --git a/packages/contracts/src/follow-ups.ts b/packages/contracts/src/follow-ups.ts new file mode 100644 index 0000000..bf2ca84 --- /dev/null +++ b/packages/contracts/src/follow-ups.ts @@ -0,0 +1,122 @@ +import type { ServiceCategory } from './service-applications.js'; + +export const FOLLOW_UP_TYPES = ['SERVICE_COMPLETION', 'KEY_CARE', 'MANUAL'] as const; +export type FollowUpType = (typeof FOLLOW_UP_TYPES)[number]; + +export const FOLLOW_UP_STATUSES = ['PENDING', 'COMPLETED', 'CANCELLED'] as const; +export type FollowUpStatus = (typeof FOLLOW_UP_STATUSES)[number]; + +export const FOLLOW_UP_RESULT_CODES = [ + 'SATISFIED', + 'GENERALLY_SATISFIED', + 'UNSATISFIED', + 'NEED_MORE_HELP', + 'UNREACHABLE', + 'OTHER', +] as const; +export type FollowUpResultCode = (typeof FOLLOW_UP_RESULT_CODES)[number]; + +export const FOLLOW_UP_TYPE_LABELS: Record = { + SERVICE_COMPLETION: '服务完成回访', + KEY_CARE: '重点关爱回访', + MANUAL: '人工回访', +}; + +export const FOLLOW_UP_STATUS_LABELS: Record = { + PENDING: '待回访', + COMPLETED: '已完成', + CANCELLED: '已取消', +}; + +export const FOLLOW_UP_RESULT_LABELS: Record = { + SATISFIED: '满意', + GENERALLY_SATISFIED: '基本满意', + UNSATISFIED: '不满意', + NEED_MORE_HELP: '需要继续帮扶', + UNREACHABLE: '未能联系', + OTHER: '其他', +}; + +export interface CompleteServiceCaseRequest { + clientRequestId: string; + resultSummary: string; + followUpPlan: string; + followUpDueAt: string; +} + +export interface CompleteServiceCaseResponse { + serviceCasePublicId: string; + serviceCaseStatus: 'SERVICE_COMPLETED'; + applicationStatus: 'FOLLOW_UP_PENDING'; + followUp: FollowUpListItem; + idempotentReplay: boolean; +} + +export interface CreateFollowUpRequest { + clientRequestId: string; + beneficiaryPublicId: string; + ownerStaffPublicId: string; + serviceCasePublicId?: string; + followUpType: Extract; + dueAt: string; + reason: string; +} + +export interface CompleteFollowUpRequest { + clientRequestId: string; + resultCode: FollowUpResultCode; + resultNote: string; + continuationConsentConfirmed?: true; + continuationDescription?: string; +} + +export interface FollowUpListItem { + publicId: string; + followUpNo: string; + beneficiaryPublicId: string; + beneficiaryDisplayName: string; + beneficiaryKeyCareFlag: boolean; + category: ServiceCategory; + serviceCasePublicId: string | null; + serviceCaseNo: string | null; + ownerStaffPublicId: string; + ownerDisplayName: string; + dueAt: string; + followUpType: FollowUpType; + followUpTypeLabel: string; + status: FollowUpStatus; + statusLabel: string; + resultCode: FollowUpResultCode | null; + resultLabel: string | null; + createdAt: string; + completedAt: string | null; +} + +export interface FollowUpDetail extends FollowUpListItem { + reason: string; + resultNote: string | null; + continuedApplicationPublicId: string | null; + continuedApplicationNo: string | null; +} + +export interface FollowUpDetailResponse { + followUp: FollowUpDetail; +} + +export interface FollowUpMutationResponse extends FollowUpDetailResponse { + idempotentReplay: boolean; +} + +export interface PaginatedFollowUpsResponse { + items: FollowUpListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface FollowUpWorkbenchSummary { + dueToday: number; + overdue: number; + dueThisWeek: number; + keyCarePending: number; +} diff --git a/packages/contracts/src/health.spec.ts b/packages/contracts/src/health.spec.ts new file mode 100644 index 0000000..fb642dc --- /dev/null +++ b/packages/contracts/src/health.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import { HEALTH_STATUS } from './health.js'; + +describe('health contract', () => { + it('uses a stable healthy status', () => { + expect(HEALTH_STATUS).toBe('ok'); + }); +}); diff --git a/packages/contracts/src/health.ts b/packages/contracts/src/health.ts new file mode 100644 index 0000000..a24c89e --- /dev/null +++ b/packages/contracts/src/health.ts @@ -0,0 +1,7 @@ +export const HEALTH_STATUS = 'ok' as const; + +export interface HealthResponse { + status: typeof HEALTH_STATUS; + service: 'api'; + timestamp: string; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..3d2d9ab --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,198 @@ +export { API_ERROR_CODES } from './api.js'; +export type { ApiErrorCode, ApiErrorResponse, ApiSuccessResponse } from './api.js'; +export { PERMISSION_CODES, ROLE_CODES, USER_STATUSES, USER_TYPES } from './auth.js'; +export type { + AdminLoginRequest, + AdminLoginResponse, + AuthUser, + CurrentUserResponse, + LogoutResponse, + PermissionCode, + RoleCode, + UserStatus, + UserType, + UpdateUserRolesRequest, + UpdateUserRolesResponse, + WechatLoginRequest, + WechatLoginResponse, +} from './auth.js'; +export { HEALTH_STATUS } from './health.js'; +export type { HealthResponse } from './health.js'; +export { + APPLICATION_APPLY_MODE_LABELS, + APPLICATION_APPLY_MODES, + SERVICE_APPLICATION_STATUS_LABELS, + SERVICE_APPLICATION_STATUSES, + SERVICE_CATEGORIES, + SERVICE_CATEGORY_LABELS, +} from './service-applications.js'; +export type { StatisticsOverview } from './statistics.js'; +export { + CONTENT_POST_STATUSES, + CONTENT_POST_STATUS_LABELS, + CONTENT_POST_TYPES, + CONTENT_POST_TYPE_LABELS, + NOTIFICATION_TYPES, + NOTIFICATION_TYPE_LABELS, +} from './content-notifications.js'; +export type { + ContentPostType, + ContentPostStatus, + ContentPostListItem, + ContentPostDetail, + ContentStatusHistoryItem, + PaginatedContentPostsResponse, + ContentPostDetailResponse, + CreateContentPostRequest, + UpdateContentPostRequest, + TransitionContentPostRequest, + NotificationType, + NotificationItem, + PaginatedNotificationsResponse, + NotificationPreferenceItem, + NotificationPreferencesResponse, + UpdateNotificationPreferencesRequest, + NotificationDeliveryItem, + PaginatedNotificationDeliveriesResponse, +} from './content-notifications.js'; +export { + FOLLOW_UP_RESULT_CODES, + FOLLOW_UP_RESULT_LABELS, + FOLLOW_UP_STATUSES, + FOLLOW_UP_STATUS_LABELS, + FOLLOW_UP_TYPES, + FOLLOW_UP_TYPE_LABELS, +} from './follow-ups.js'; +export { + ACTIVITY_STATUSES, + ACTIVITY_STATUS_LABELS, + CHECK_IN_STATUSES, + CHECK_IN_STATUS_LABELS, + PROJECT_STATUSES, + PROJECT_STATUS_LABELS, + REGISTRATION_STATUSES, + REGISTRATION_STATUS_LABELS, + VOLUNTEER_SKILL_CODES, + VOLUNTEER_SKILL_LABELS, + VOLUNTEER_STATUSES, + VOLUNTEER_STATUS_LABELS, +} from './participation.js'; +export type { + ActivityCheckInStatus, + ActivityDetail, + ActivityDetailResponse, + ActivityListItem, + ActivityRegistrationItem, + ActivityRegistrationResponse, + ActivityRegistrationStatus, + ActivityStatus, + AdminVolunteerDetailResponse, + AdminVolunteerListItem, + ApplyVolunteerRequest, + CheckInActivityRegistrationRequest, + CreateActivityRequest, + CreateMediaUploadIntentRequest, + CreateProjectRequest, + PaginatedActivitiesResponse, + PaginatedActivityRegistrationsResponse, + PaginatedProjectsResponse, + PaginatedVolunteersResponse, + ProjectDetail, + ProjectDetailResponse, + ProjectListItem, + ProjectStatus, + RecordServiceMinutesRequest, + RegisterActivityRequest, + ReplaceProjectCasesRequest, + RestoreVolunteerRequest, + ReviewActivityRegistrationRequest, + ReviewVolunteerRequest, + TransitionActivityRequest, + TransitionProjectRequest, + UpdateActivityRequest, + UpdateProjectRequest, + VolunteerProfile, + VolunteerProfileResponse, + VolunteerSkillCode, + VolunteerStatus, +} from './participation.js'; +export type { + CompleteFollowUpRequest, + CompleteServiceCaseRequest, + CompleteServiceCaseResponse, + CreateFollowUpRequest, + FollowUpDetail, + FollowUpDetailResponse, + FollowUpListItem, + FollowUpMutationResponse, + FollowUpResultCode, + FollowUpStatus, + FollowUpType, + FollowUpWorkbenchSummary, + PaginatedFollowUpsResponse, +} from './follow-ups.js'; +export { + BENEFICIARY_CONFIRM_STATUSES, + BENEFICIARY_GENDERS, + BENEFICIARY_STATUSES, + CASE_ASSIGNEE_TYPES, + SERVICE_CASE_PRIORITIES, + SERVICE_CASE_PRIORITY_LABELS, + SERVICE_CASE_STATUSES, + SERVICE_CASE_STATUS_LABELS, + SERVICE_RECORD_TYPES, + SERVICE_RECORD_TYPE_LABELS, +} from './case-management.js'; +export type { + AcceptServiceApplicationRequest, + AcceptServiceApplicationResponse, + AssigneeOption, + BeneficiaryConfirmStatus, + BeneficiaryContactDetail, + BeneficiaryContactInput, + BeneficiaryDetail, + BeneficiaryDetailResponse, + BeneficiaryGender, + BeneficiaryListItem, + BeneficiaryStatus, + BeneficiaryTagOption, + CaseAssigneeItem, + CaseAssigneeType, + ConfirmUploadResponse, + CreateServiceRecordRequest, + CreateUploadIntentRequest, + CreateUploadIntentResponse, + DownloadUrlResponse, + NewBeneficiaryInput, + PaginatedBeneficiariesResponse, + PaginatedServiceCasesResponse, + ReplaceCaseAssigneesRequest, + ServiceCaseDetail, + ServiceCaseDetailResponse, + ServiceCaseHistoryItem, + ServiceCaseListItem, + ServiceCasePriority, + ServiceCaseStatus, + ServiceRecordAttachment, + ServiceRecordItem, + ServiceRecordResponse, + ServiceRecordType, + TransitionServiceApplicationRequest, + UpdateBeneficiaryRequest, + UpdateServiceRecordRequest, +} from './case-management.js'; +export type { + AdminServiceApplicationQuery, + ApplicationApplyMode, + CreateServiceApplicationRequest, + CreateServiceApplicationResponse, + PaginatedServiceApplicationsResponse, + ServiceApplicationDetail, + ServiceApplicationDetailResponse, + ServiceApplicationHistoryItem, + ServiceApplicationListItem, + ServiceApplicationStatus, + ServiceCategoriesResponse, + ServiceCategory, + ServiceCategoryOption, +} from './service-applications.js'; diff --git a/packages/contracts/src/participation.ts b/packages/contracts/src/participation.ts new file mode 100644 index 0000000..bb2304d --- /dev/null +++ b/packages/contracts/src/participation.ts @@ -0,0 +1,363 @@ +import type { ServiceCategory } from './service-applications.js'; + +export const VOLUNTEER_STATUSES = [ + 'APPLIED', + 'APPROVED', + 'REJECTED', + 'SUSPENDED', + 'INACTIVE', +] as const; +export type VolunteerStatus = (typeof VOLUNTEER_STATUSES)[number]; + +export const VOLUNTEER_STATUS_LABELS: Record = { + APPLIED: '待审核', + APPROVED: '已认证', + REJECTED: '未通过', + SUSPENDED: '已暂停', + INACTIVE: '已停用', +}; + +export const VOLUNTEER_SKILL_CODES = [ + 'ELDER_CARE', + 'CHILD_CARE', + 'DISABILITY_SUPPORT', + 'EVENT_SUPPORT', + 'FIRST_AID', + 'PHOTOGRAPHY', +] as const; +export type VolunteerSkillCode = (typeof VOLUNTEER_SKILL_CODES)[number]; + +export const VOLUNTEER_SKILL_LABELS: Record = { + ELDER_CARE: '老年关怀', + CHILD_CARE: '儿童陪伴', + DISABILITY_SUPPORT: '残障支持', + EVENT_SUPPORT: '活动协助', + FIRST_AID: '急救技能', + PHOTOGRAPHY: '摄影记录', +}; + +export const PROJECT_STATUSES = ['DRAFT', 'PUBLISHED', 'COMPLETED'] as const; +export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export const PROJECT_STATUS_LABELS: Record = { + DRAFT: '草稿', + PUBLISHED: '已发布', + COMPLETED: '已完成', +}; + +export const ACTIVITY_STATUSES = [ + 'DRAFT', + 'PUBLISHED', + 'REGISTRATION_CLOSED', + 'IN_PROGRESS', + 'COMPLETED', + 'CANCELLED', +] as const; +export type ActivityStatus = (typeof ACTIVITY_STATUSES)[number]; +export const ACTIVITY_STATUS_LABELS: Record = { + DRAFT: '草稿', + PUBLISHED: '报名中', + REGISTRATION_CLOSED: '报名已截止', + IN_PROGRESS: '进行中', + COMPLETED: '已完成', + CANCELLED: '已取消', +}; + +export const REGISTRATION_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'] as const; +export type ActivityRegistrationStatus = (typeof REGISTRATION_STATUSES)[number]; +export const REGISTRATION_STATUS_LABELS: Record = { + PENDING: '待审核', + APPROVED: '报名成功', + REJECTED: '未通过', + CANCELLED: '已取消', +}; + +export const CHECK_IN_STATUSES = ['NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT'] as const; +export type ActivityCheckInStatus = (typeof CHECK_IN_STATUSES)[number]; +export const CHECK_IN_STATUS_LABELS: Record = { + NOT_CHECKED_IN: '未签到', + CHECKED_IN: '已签到', + ABSENT: '未到场', +}; + +export interface ApplyVolunteerRequest { + clientRequestId: string; + expectedRevision?: number; + realName: string; + mobile: string; + regionCode: string; + availabilityNote: string; + skillCodes: VolunteerSkillCode[]; + interestCategories: ServiceCategory[]; + privacyConsent: true; +} + +export interface VolunteerStatusHistoryItem { + status: VolunteerStatus; + statusLabel: string; + reasonLabel: string; + note: string | null; + createdAt: string; +} + +export interface VolunteerProfile { + publicId: string; + realName: string; + mobile: string; + mobileMasked: string; + regionCode: string; + availabilityNote: string; + skillCodes: VolunteerSkillCode[]; + skillLabels: string[]; + interestCategories: ServiceCategory[]; + status: VolunteerStatus; + statusLabel: string; + revision: number; + totalServiceMinutes: number; + completedActivityCount: number; + history: VolunteerStatusHistoryItem[]; + createdAt: string; +} + +export interface VolunteerProfileResponse { + volunteer: VolunteerProfile | null; + idempotentReplay?: boolean; +} + +export interface AdminVolunteerListItem { + publicId: string; + realName: string; + mobileMasked: string; + regionCode: string; + skillLabels: string[]; + interestCategories: ServiceCategory[]; + status: VolunteerStatus; + statusLabel: string; + totalServiceMinutes: number; + completedActivityCount: number; + createdAt: string; +} + +export interface PaginatedVolunteersResponse { + items: AdminVolunteerListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface AdminVolunteerDetailResponse { + volunteer: VolunteerProfile; +} + +export interface ReviewVolunteerRequest { + clientRequestId: string; + targetStatus: Extract; + note: string; +} + +export interface RestoreVolunteerRequest { + clientRequestId: string; + targetStatus: 'APPROVED'; + note: string; +} + +export interface CreateProjectRequest { + clientRequestId: string; + name: string; + category: ServiceCategory; + summary: string; + content: string; + coverFilePublicId?: string; + managerUserPublicId: string; + startDate: string; + endDate: string; + targetPeople?: number; + isPublic: boolean; + sortOrder: number; +} + +export interface UpdateProjectRequest extends Omit { + expectedRevision: number; +} + +export interface ProjectStatusHistoryItem { + status: ProjectStatus; + statusLabel: string; + reasonLabel: string; + createdAt: string; +} + +export interface ProjectListItem { + publicId: string; + projectNo: string; + name: string; + category: ServiceCategory; + summary: string; + coverImageUrl: string | null; + managerUserPublicId: string; + managerDisplayName: string; + startDate: string; + endDate: string; + targetPeople: number | null; + status: ProjectStatus; + statusLabel: string; + isPublic: boolean; + sortOrder: number; + revision: number; + activityCount: number; + serviceCaseCount: number; + createdAt: string; +} + +export interface ProjectDetail extends ProjectListItem { + content: string; + coverFilePublicId: string | null; + history: ProjectStatusHistoryItem[]; +} + +export interface ProjectDetailResponse { + project: ProjectDetail; + idempotentReplay?: boolean; +} + +export interface PaginatedProjectsResponse { + items: ProjectListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface TransitionProjectRequest { + clientRequestId: string; + targetStatus: Extract; +} + +export interface ReplaceProjectCasesRequest { + serviceCasePublicIds: string[]; +} + +export interface CreateActivityRequest { + clientRequestId: string; + projectPublicId?: string; + title: string; + summary: string; + content: string; + coverFilePublicId?: string; + startAt: string; + endAt: string; + registrationDeadline: string; + locationText: string; + capacity?: number; +} + +export interface UpdateActivityRequest extends Omit { + expectedRevision: number; +} + +export interface ActivityStatusHistoryItem { + status: ActivityStatus; + statusLabel: string; + reasonLabel: string; + createdAt: string; +} + +export interface ActivityListItem { + publicId: string; + activityNo: string; + projectPublicId: string | null; + projectName: string | null; + title: string; + summary: string; + coverImageUrl: string | null; + startAt: string; + endAt: string; + registrationDeadline: string; + locationText: string; + capacity: number | null; + activeRegistrationCount: number; + remainingCapacity: number | null; + status: ActivityStatus; + statusLabel: string; + canRegister: boolean; + revision: number; + createdAt: string; +} + +export interface ActivityDetail extends ActivityListItem { + content: string; + coverFilePublicId: string | null; + history: ActivityStatusHistoryItem[]; +} + +export interface ActivityDetailResponse { + activity: ActivityDetail; + currentUserRegistration: ActivityRegistrationItem | null; + idempotentReplay?: boolean; +} + +export interface PaginatedActivitiesResponse { + items: ActivityListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface TransitionActivityRequest { + clientRequestId: string; + targetStatus: Exclude; +} + +export interface RegisterActivityRequest { + clientRequestId: string; +} + +export interface ActivityRegistrationItem { + publicId: string; + activityPublicId: string; + activityTitle: string; + volunteerPublicId: string; + volunteerName: string; + mobileMasked: string; + status: ActivityRegistrationStatus; + statusLabel: string; + checkInStatus: ActivityCheckInStatus; + checkInStatusLabel: string; + serviceMinutes: number | null; + registeredAt: string; +} + +export interface ActivityRegistrationResponse { + registration: ActivityRegistrationItem; + idempotentReplay: boolean; +} + +export interface PaginatedActivityRegistrationsResponse { + items: ActivityRegistrationItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface ReviewActivityRegistrationRequest { + clientRequestId: string; + targetStatus: Extract; + note: string; +} + +export interface CheckInActivityRegistrationRequest { + clientRequestId: string; + targetStatus: Extract; +} + +export interface RecordServiceMinutesRequest { + clientRequestId: string; + serviceMinutes: number; +} + +export interface CreateMediaUploadIntentRequest { + clientRequestId: string; + businessType: 'PROJECT_COVER' | 'ACTIVITY_COVER'; + filename: string; + mimeType: 'image/jpeg' | 'image/png' | 'image/webp'; + size: number; + sha256?: string; +} diff --git a/packages/contracts/src/service-applications.spec.ts b/packages/contracts/src/service-applications.spec.ts new file mode 100644 index 0000000..9088484 --- /dev/null +++ b/packages/contracts/src/service-applications.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { + APPLICATION_APPLY_MODE_LABELS, + SERVICE_APPLICATION_STATUS_LABELS, + SERVICE_CATEGORIES, + SERVICE_CATEGORY_LABELS, +} from './index.js'; + +describe('service application contracts', () => { + it('keeps the six approved V1 service categories stable', () => { + expect(SERVICE_CATEGORIES).toHaveLength(6); + expect(SERVICE_CATEGORY_LABELS.OLD_AGE).toBe('关爱老人'); + expect(SERVICE_CATEGORY_LABELS.CHILD).toBe('儿童关爱'); + }); + + it('provides user-facing relationship and status labels', () => { + expect(APPLICATION_APPLY_MODE_LABELS.FAMILY).toBe('帮家人申请'); + expect(SERVICE_APPLICATION_STATUS_LABELS.PENDING_CONTACT).toBe('工作人员正在联系'); + }); +}); diff --git a/packages/contracts/src/service-applications.ts b/packages/contracts/src/service-applications.ts new file mode 100644 index 0000000..6d520fd --- /dev/null +++ b/packages/contracts/src/service-applications.ts @@ -0,0 +1,149 @@ +export const SERVICE_CATEGORIES = [ + 'OLD_AGE', + 'WOMEN', + 'CHILD', + 'DISABILITY', + 'LIFE_DIFFICULTY', + 'OTHER', +] as const; +export type ServiceCategory = (typeof SERVICE_CATEGORIES)[number]; + +export const APPLICATION_APPLY_MODES = [ + 'SELF', + 'FAMILY', + 'COMMUNITY', + 'VOLUNTEER', + 'OTHER', +] as const; +export type ApplicationApplyMode = (typeof APPLICATION_APPLY_MODES)[number]; + +export const SERVICE_APPLICATION_STATUSES = [ + 'SUBMITTED', + 'PENDING_CONTACT', + 'ASSESSING', + 'REJECTED', + 'OUT_OF_SCOPE', + 'REFERRED', + 'ACCEPTED', + 'SERVICE_PLANNED', + 'IN_SERVICE', + 'SERVICE_COMPLETED', + 'FOLLOW_UP_PENDING', + 'CLOSED', + 'CANCELLED', + 'SUSPENDED', +] as const; +export type ServiceApplicationStatus = (typeof SERVICE_APPLICATION_STATUSES)[number]; + +export const SERVICE_CATEGORY_LABELS: Record = { + OLD_AGE: '关爱老人', + WOMEN: '困境妇女', + CHILD: '儿童关爱', + DISABILITY: '残障帮扶', + LIFE_DIFFICULTY: '生活困难', + OTHER: '其他服务', +}; + +export const APPLICATION_APPLY_MODE_LABELS: Record = { + SELF: '为自己申请', + FAMILY: '帮家人申请', + COMMUNITY: '社区或机构代办', + VOLUNTEER: '志愿者代办', + OTHER: '其他代办', +}; + +export const SERVICE_APPLICATION_STATUS_LABELS: Record = { + SUBMITTED: '已提交', + PENDING_CONTACT: '工作人员正在联系', + ASSESSING: '需求评估中', + REJECTED: '暂未通过', + OUT_OF_SCOPE: '超出当前服务范围', + REFERRED: '已协助转介', + ACCEPTED: '已受理', + SERVICE_PLANNED: '服务安排中', + IN_SERVICE: '服务进行中', + SERVICE_COMPLETED: '服务已完成', + FOLLOW_UP_PENDING: '等待回访', + CLOSED: '服务已结束', + CANCELLED: '申请已取消', + SUSPENDED: '服务暂缓', +}; + +export interface ServiceCategoryOption { + code: ServiceCategory; + label: string; + description: string; +} + +export interface ServiceCategoriesResponse { + items: ServiceCategoryOption[]; +} + +export interface CreateServiceApplicationRequest { + clientRequestId: string; + serviceCategory: ServiceCategory; + applyMode: ApplicationApplyMode; + beneficiaryName: string; + contactMobile: string; + regionCode: string; + description: string; + preferredContactTime?: string; + needsHomeVisit?: boolean; + mobilityDifficulty?: boolean; + privacyConsent: true; +} + +export interface ServiceApplicationHistoryItem { + status: ServiceApplicationStatus; + statusLabel: string; + reasonLabel: string; + createdAt: string; +} + +export interface ServiceApplicationListItem { + publicId: string; + applicationNo: string; + serviceCategory: ServiceCategory; + serviceCategoryLabel: string; + applyMode: ApplicationApplyMode; + applyModeLabel: string; + beneficiaryName: string; + contactMobileMasked: string; + regionCode: string; + status: ServiceApplicationStatus; + statusLabel: string; + submittedAt: string; +} + +export interface ServiceApplicationDetail extends ServiceApplicationListItem { + contactMobile: string; + description: string; + preferredContactTime: string | null; + needsHomeVisit: boolean | null; + mobilityDifficulty: boolean | null; + history: ServiceApplicationHistoryItem[]; +} + +export interface CreateServiceApplicationResponse { + application: ServiceApplicationDetail; + idempotentReplay: boolean; +} + +export interface PaginatedServiceApplicationsResponse { + items: ServiceApplicationListItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface ServiceApplicationDetailResponse { + application: ServiceApplicationDetail; +} + +export interface AdminServiceApplicationQuery { + page?: number; + pageSize?: number; + applicationNo?: string; + serviceCategory?: ServiceCategory; + status?: ServiceApplicationStatus; +} diff --git a/packages/contracts/src/statistics.ts b/packages/contracts/src/statistics.ts new file mode 100644 index 0000000..dd3687a --- /dev/null +++ b/packages/contracts/src/statistics.ts @@ -0,0 +1,15 @@ +import type { ServiceCategory } from './service-applications.js'; +export interface StatisticsOverview { + period: { startDate: string; endDate: string }; + applicationCount: number; + acceptedCount: number; + completedServiceCount: number; + servicePersonTimes: number; + uniqueBeneficiaryCount: number; + volunteerCount: number; + activityCount: number; + volunteerMinutes: number; + byCategory: Array<{ category: ServiceCategory; count: number }>; + monthly: Array<{ month: string; applications: number; completedServices: number }>; + generatedAt: string; +} diff --git a/packages/contracts/tsconfig.build.json b/packages/contracts/tsconfig.build.json new file mode 100644 index 0000000..1fd5717 --- /dev/null +++ b/packages/contracts/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist", + "sourceMap": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..c5d5037 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f3ea909 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5360 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + js-yaml: 5.2.2 + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.39.5 + eslint: + specifier: ^9.33.0 + version: 9.39.5(jiti@2.7.0) + globals: + specifier: ^16.3.0 + version: 16.3.0 + prettier: + specifier: ^3.6.2 + version: 3.9.6 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.39.1 + version: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + + apps/admin-web: + dependencies: + '@lehuo/contracts': + specifier: workspace:* + version: link:../../packages/contracts + pinia: + specifier: ^3.0.3 + version: 3.0.3(typescript@5.9.2)(vue@3.5.41(typescript@5.9.2)) + vue: + specifier: ^3.5.20 + version: 3.5.41(typescript@5.9.2) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.41(typescript@5.9.2)) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.1 + version: 6.0.8(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11))(vue@3.5.41(typescript@5.9.2)) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vite: + specifier: ^7.1.3 + version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + vue-tsc: + specifier: ^3.0.6 + version: 3.3.9(typescript@5.9.2) + + apps/api: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1106.0 + version: 3.1106.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1106.0 + version: 3.1106.0 + '@lehuo/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@nestjs/common': + specifier: ^11.1.6 + version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.6 + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.1.6 + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/swagger': + specifier: ^11.4.6 + version: 11.4.6(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2) + '@prisma/adapter-mariadb': + specifier: ^7.9.1 + version: 7.9.1 + '@prisma/client': + specifier: ^7.9.1 + version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.2))(typescript@5.9.2) + argon2: + specifier: ^0.45.1 + version: 0.45.1 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.15.1 + version: 0.15.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + mariadb: + specifier: ^3.5.3 + version: 3.5.3 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + devDependencies: + '@nestjs/testing': + specifier: ^11.1.6 + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^24.3.0 + version: 24.13.3 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + prisma: + specifier: ^7.9.1 + version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.2) + supertest: + specifier: ^7.1.4 + version: 7.2.2 + tsx: + specifier: ^4.20.5 + version: 4.23.11 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + + apps/miniprogram: + dependencies: + '@lehuo/contracts': + specifier: workspace:* + version: link:../../packages/contracts + devDependencies: + '@types/wechat-miniprogram': + specifier: ^3.4.8 + version: 3.4.10 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + + packages/contracts: + devDependencies: + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + +packages: + + '@aws-sdk/checksums@3.1000.26': + resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1106.0': + resolution: {integrity: sha512-hUTlnyRRGlVdfvJLL3hCEnMm7CmunSzc/lxFVRX8g1fjJTMUVbyQCfhfMhp7dZ7JBftLU6OYresu3Hje4nvkJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.72': + resolution: {integrity: sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1106.0': + resolution: {integrity: sha512-ZI5SCkyz8jB3Qr6NPSJ7R4A/PkniQvbD1DO8APwhMsubFcfwPSJMMUxrkv9k1E20ikRk7skpM9itZh+0wI9K/w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nestjs/common@11.1.28': + resolution: {integrity: sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.28': + resolution: {integrity: sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.28': + resolution: {integrity: sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/swagger@11.4.6': + resolution: {integrity: sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@11.1.28': + resolution: {integrity: sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + + '@noble/hashes@1.1.5': + resolution: {integrity: sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==} + + '@paralleldrive/cuid2@2.2.2': + resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@prisma/adapter-mariadb@7.9.1': + resolution: {integrity: sha512-4aZUbIg2av7XFSQyM9cQeAp9YP9HiPr8H7ehe2rDo375IZXILEzQrNXZ/zLetgRQ2ojlaUl/brArFdOVS3n1xA==} + + '@prisma/client-runtime-utils@7.9.1': + resolution: {integrity: sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==} + + '@prisma/client@7.9.1': + resolution: {integrity: sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.9.1': + resolution: {integrity: sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.9.1': + resolution: {integrity: sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==} + + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} + + '@prisma/driver-adapter-utils@7.9.1': + resolution: {integrity: sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.1': + resolution: {integrity: sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==} + + '@prisma/fetch-engine@7.9.1': + resolution: {integrity: sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.9.1': + resolution: {integrity: sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/wechat-miniprogram@3.4.10': + resolution: {integrity: sha512-NG8mvXxQABE+gUHO2DAnK0W6f2hLT9AvWqzCxjqGUvGuRNkegMomzjm1z3LNPrgm9kfI8OhMO7kbilhwpREriw==} + + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.66.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/language-core@3.3.9': + resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.0.0: + resolution: {integrity: sha512-8zjUtFJ3db/QoPXuuEMloS2AUf79/yeyttJ7Abr3hteopJu9HK8vsgGviGUMq+zyA6cZZO6gAyZoMTF6TgaEjA==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + ansi-styles@6.1.0: + resolution: {integrity: sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==} + engines: {node: '>=12'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + argon2@0.45.1: + resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==} + engines: {node: '>=16.17.0'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + + birpc@2.3.0: + resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.1: + resolution: {integrity: sha512-u/MudsoQEgBUZgR5N1v87vEgybeVYus9VnDVaIkxkkGP2jt54naghQ3PCQHJiogS8U/GavZCUPFfx3Xkp+NaHw==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.0: + resolution: {integrity: sha512-EryKbCE/wxpxKniQlyas6PY1I9vwtF3uCBweX+N8KYTCn3Y12RTGtQAJ/bd5pl7kxUAc8v/R3Ake/N17OZiFqA==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + foreground-child@3.1.0: + resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==} + engines: {node: '>=14'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.3.0: + resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.2.1: + resolution: {integrity: sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} + engines: {node: '>=14'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.13.10: + resolution: {integrity: sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mariadb@3.4.5: + resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} + engines: {node: '>= 14'} + + mariadb@3.5.3: + resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==} + engines: {node: '>= 20.0.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.40.0: + resolution: {integrity: sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.24: + resolution: {integrity: sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.0.2: + resolution: {integrity: sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pinia@3.0.3: + resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + prisma@7.9.1: + resolution: {integrity: sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} + engines: {node: '>=14'} + hasBin: true + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-width@4.2.0: + resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.11: + resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.3.9: + resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.1: + resolution: {integrity: sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + +snapshots: + + '@aws-sdk/checksums@3.1000.26': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1106.0': + dependencies: + '@aws-sdk/checksums': 3.1000.26 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/middleware-sdk-s3': 3.972.72 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.6': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.73': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.41': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1106.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1103.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@borewit/text-codec@0.2.2': {} + + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} + + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.2.0 + import-fresh: 3.2.1 + js-yaml: 5.2.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.0 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 5.2.2 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.8 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@noble/hashes@1.1.5': {} + + '@paralleldrive/cuid2@2.2.2': + dependencies: + '@noble/hashes': 1.1.5 + + '@phc/format@1.0.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@prisma/adapter-mariadb@7.9.1': + dependencies: + '@prisma/driver-adapter-utils': 7.9.1 + mariadb: 3.4.5 + + '@prisma/client-runtime-utils@7.9.1': {} + + '@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.2))(typescript@5.9.2)': + dependencies: + '@prisma/client-runtime-utils': 7.9.1 + optionalDependencies: + prisma: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.2) + typescript: 5.9.2 + + '@prisma/config@7.9.1': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.9.1': {} + + '@prisma/dev@0.24.17(typescript@5.9.2)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.4.2(typescript@5.9.2) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.1 + '@prisma/get-platform': 7.9.1 + + '@prisma/fetch-engine@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.1 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + + '@standard-schema/spec@1.1.0': {} + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.13.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.13.3 + + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + + '@types/cookiejar@2.1.5': {} + + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 24.13.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/geojson@7946.0.16': {} + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash@4.17.25': {} + + '@types/methods@1.1.4': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.13.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.13.3 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.13.3 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + + '@types/wechat-miniprogram@3.4.10': {} + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.66.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.66.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.2) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.66.0': {} + + '@typescript-eslint/typescript-estree@8.66.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/project-service': 8.66.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.2) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.2) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 + + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.18 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + classnames: 2.5.1 + react: 19.2.8 + + '@visx/group@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + classnames: 2.5.1 + react: 19.2.8 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + + '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11))(vue@3.5.41(typescript@5.9.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + vue: 3.5.41(typescript@5.9.2) + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.3.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@3.3.9': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@3.2.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.0.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.1.0: {} + + append-field@1.0.0: {} + + argon2@0.45.1: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.1 + node-gyp-build: 4.8.4 + + argparse@2.0.1: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + aws-ssl-profiles@1.1.2: {} + + balanced-match@1.0.0: {} + + balanced-match@4.0.4: {} + + better-result@2.10.0: {} + + birpc@2.3.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bowser@2.14.1: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.0 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.0 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.0.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + check-error@2.1.3: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + class-transformer@0.5.1: {} + + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.10 + validator: 13.15.35 + + classnames@2.5.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.2.4: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.1: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.1 + + csstype@3.2.3: {} + + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-format@3.1.2: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.1 + + elkjs@0.11.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + entities@7.0.1: {} + + env-paths@3.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.0.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.2.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.1: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.5: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.0 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.0: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + rimraf: 5.0.5 + + flatted@3.4.4: {} + + foreground-child@3.1.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.2.2 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-port-please@3.2.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + giget@3.3.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.1.0 + jackspeak: 3.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + globals@16.3.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.2.0: {} + + ignore@7.0.6: {} + + import-fresh@3.2.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + internmap@2.0.3: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-what@5.5.0: {} + + isexe@2.0.0: {} + + iterare@1.2.1: {} + + jackspeak@3.1.2: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.7.0: {} + + js-tokens@9.0.1: {} + + js-yaml@5.2.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.13.10: {} + + load-esm@1.0.3: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + long@5.3.2: {} + + loupe@3.2.1: {} + + lru-cache@10.2.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru.min@1.1.4: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mariadb@3.4.5: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.13.3 + denque: 2.1.0 + iconv-lite: 0.6.3 + lru-cache: 10.4.3 + + mariadb@3.5.3: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.13.3 + denque: 2.1.0 + iconv-lite: 0.7.3 + lru-cache: 11.5.2 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.40.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.24: + dependencies: + mime-db: 1.40.0 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-addon-api@8.9.1: {} + + node-gyp-build@4.8.4: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.0.2: + dependencies: + p-try: 2.2.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.0.2 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pinia@3.0.3(typescript@5.9.2)(vue@3.5.41(typescript@5.9.2)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.41(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.7: {} + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.2): + dependencies: + '@prisma/config': 7.9.1 + '@prisma/dev': 0.24.17(typescript@5.9.2) + '@prisma/engines': 7.9.1 + '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@5.1.1: {} + + reflect-metadata@0.2.2: {} + + remeda@2.33.4: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + ret@0.5.0: {} + + retry@0.12.0: {} + + rfdc@1.4.1: {} + + rimraf@5.0.5: + dependencies: + glob: 10.5.0 + + robust-predicates@3.0.3: {} + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seq-queue@0.0.5: {} + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + sqlstring@2.3.3: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + std-env@3.9.0: {} + + streamsearch@1.1.0: {} + + string-width@4.2.0: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + ts-api-utils@2.5.0(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + tslib@2.8.1: {} + + tsx@4.23.11: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.24 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + typescript@5.9.2: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + valibot@1.4.2(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + + validator@13.15.35: {} + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.11 + + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.2.0 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.11) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-uri@3.1.0: {} + + vue-router@4.6.4(vue@3.5.41(typescript@5.9.2)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.41(typescript@5.9.2) + + vue-tsc@3.3.9(typescript@5.9.2): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.9 + typescript: 5.9.2 + + vue@3.5.41(typescript@5.9.2): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 5.9.2 + + which@2.0.1: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.0.0 + string-width: 4.2.0 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.1.0 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..81d755c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +packages: + - apps/* + - packages/* + +overrides: + js-yaml: 5.2.2 + +allowBuilds: + '@nestjs/core': true + '@prisma/engines': true + '@scarf/scarf': false + argon2: true + esbuild: true + prisma: true diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..20a7ee2 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + } +} diff --git a/乐活公益小程序_Codex开发总文档_V1.0.md b/乐活公益小程序_Codex开发总文档_V1.0.md new file mode 100644 index 0000000..5ae3e7c --- /dev/null +++ b/乐活公益小程序_Codex开发总文档_V1.0.md @@ -0,0 +1,2978 @@ + + +# 乐活公益·暖心服务平台 —— Codex 开发文档包 + +> 版本:V1.0 +> 文档用途:作为 Codex / AI 编程智能体的长期开发上下文、项目规则和验收依据。 +> 最高原则:任何代码实现不得偏离本目录文档。发生冲突时,按本文档规定的优先级处理。 + +--- + +## 1. 项目一句话定义 + +“乐活公益·暖心服务平台”不是公益宣传页,而是一套以微信小程序为公众入口、Web 管理后台为工作人员入口、以“需求申请 → 受理评估 → 建档 → 派单 → 服务 → 反馈 → 回访 → 持续关爱”为核心闭环的公益服务数字化平台。 + +主要服务领域: + +1. 关爱老年人、为老服务; +2. 困境妇女慈善帮扶; +3. 困境儿童关爱与帮扶; +4. 残疾人爱心救助与帮扶; +5. 其他困难群体服务; +6. 志愿者组织、公益活动、公益资源和社会参与。 + +--- + +## 2. Codex 每次开始工作前必须执行 + +按顺序阅读: + +1. `00_README_FIRST.md` +2. `01_PROJECT_CONSTITUTION.md` +3. `02_PRODUCT_PRD_V1.md` +4. `03_ARCHITECTURE.md` +5. `04_DATA_MODEL.md` +6. `05_API_AND_WORKFLOW.md` +7. `06_SECURITY_PRIVACY.md` +8. `07_UI_UX.md` +9. `08_ENGINEERING_RULES.md` +10. `09_TEST_ACCEPTANCE.md` +11. `10_TASK_STATE.md` +12. `11_DECISIONS.md` + +如果只是小范围修复,也至少必须阅读: + +- `01_PROJECT_CONSTITUTION.md` +- `10_TASK_STATE.md` +- 与修改模块相关的文档 +- `11_DECISIONS.md` + +不得仅根据聊天中最近一句话修改项目。 + +--- + +## 3. 开发结束后必须回写 + +每完成一个任务,必须: + +1. 更新 `10_TASK_STATE.md`; +2. 若产生架构/技术/业务决策,更新 `11_DECISIONS.md`; +3. 若接口变化,更新 API 文档; +4. 若表结构变化,更新数据模型与 migration; +5. 增加或更新自动化测试; +6. 执行 lint、typecheck、unit test、integration test、build; +7. 输出本次变更摘要、影响范围、风险、回滚方式。 + +禁止“只改代码、不改文档”。 + +--- + +## 4. 冲突处理优先级 + +从高到低: + +1. 数据安全、隐私、法律合规; +2. `01_PROJECT_CONSTITUTION.md` 中的硬约束; +3. 已确认的产品业务规则; +4. 数据一致性和可审计性; +5. API 契约; +6. UI/UX 规范; +7. 工程偏好; +8. Codex 自己认为“更先进”的方案。 + +如果 Codex 认为有更好的方案,但会改变既有边界,必须记录到 `11_DECISIONS.md` 的“待确认建议”,不能擅自实施。 + +--- + +## 5. 代码仓库建议结构 + +```text +lehuo-public-service/ +├─ apps/ +│ ├─ miniprogram/ # 微信小程序 +│ ├─ admin-web/ # Web 管理后台 +│ └─ api/ # 后端 API +├─ packages/ +│ ├─ contracts/ # DTO / enum / schema / API 契约 +│ ├─ shared/ # 无业务副作用的共享代码 +│ ├─ eslint-config/ +│ └─ tsconfig/ +├─ infra/ +│ ├─ docker/ +│ ├─ nginx/ +│ ├─ scripts/ +│ └─ backup/ +├─ docs/ +│ ├─ 本文档包 +│ └─ diagrams/ +├─ migrations/ +├─ .env.example +├─ pnpm-workspace.yaml +├─ package.json +├─ AGENTS.md +└─ README.md +``` + +--- + +## 6. AGENTS.md 必须包含 + +仓库根目录 `AGENTS.md` 应复制以下核心规则: + +- 先读 `docs/00_README_FIRST.md` 与 `docs/01_PROJECT_CONSTITUTION.md`。 +- 不擅自改变产品定位、技术栈、数据库类型、认证方式。 +- 不删除既有功能来“简化实现”。 +- 不通过 mock 假装功能完成。 +- 不将敏感信息写入日志。 +- 每个业务状态变化必须可审计。 +- 所有表结构变化必须 migration。 +- 所有 API 变更必须同步 contracts。 +- 完成任务必须更新 `docs/10_TASK_STATE.md`。 +- 任何架构决策必须更新 `docs/11_DECISIONS.md`。 +- 不因“AI认为更合理”而推翻已经批准的业务规则。 + + + +# 01 项目开发宪法(不可随意修改) + +## 1. 产品定位硬约束 + +本项目必须始终是: + +> 公益服务数字化平台,而不是宣传网站、商城、社交平台或复杂互联网平台。 + +V1.0 第一目标:让真实服务闭环跑通。 + +主链路: + +```text +服务对象/代申请人提交需求 +→ 工作人员受理 +→ 需求评估 +→ 建立/关联服务对象档案 +→ 创建服务工单 +→ 分配工作人员/志愿者 +→ 执行服务 +→ 留痕 +→ 服务对象确认/反馈 +→ 回访 +→ 必要时继续跟进 +``` + +任何新功能都必须回答:“是否增强这条主链路?” + +--- + +## 2. 用户体验硬约束 + +1. 小程序优先服务普通群众、老人和低数字技能用户。 +2. 主要操作不得依赖复杂手势。 +3. 核心按钮大、清楚、可理解。 +4. 服务申请尽量在 3~4 个步骤内完成。 +5. 表单遵循“少填、可选、可拍、可说、可代办”。 +6. 不在前端暴露技术术语、内部状态码。 +7. 不为了视觉炫技牺牲加载速度和可理解性。 +8. 所有失败状态必须给普通人能看懂的说明与下一步动作。 +9. 适老化不是“放大字体”这么简单,必须包含点击区域、对比度、信息密度、步骤数量、错误恢复能力。 +10. 功能完成度优先于动画和装饰。 + +--- + +## 3. 技术硬约束 + +1. 国内网络环境可稳定开发、构建、部署和访问。 +2. 微信小程序作为公众端。 +3. Web 管理后台作为机构工作人员端。 +4. 后端采用模块化单体优先,V1.0 禁止为了“先进”拆微服务。 +5. MySQL 为核心关系数据库。 +6. Redis 只用于缓存、限流、短期状态等可恢复数据,不作为业务事实唯一存储。 +7. 文件不保存到应用容器本地磁盘;生产环境使用对象存储。 +8. 必须 Docker 化。 +9. 环境分 dev / test / staging / prod。 +10. 配置必须环境变量化,禁止把密钥提交 Git。 +11. 所有数据库修改必须 migration。 +12. 生产部署必须有备份、恢复、日志、监控和健康检查。 +13. 对外部供应商能力必须封装 Adapter,不让业务层直接绑定云厂商 SDK。 +14. V1.0 不引入 Kubernetes。 +15. V1.0 不引入复杂事件总线,必要异步任务先使用可靠 Job/Queue 模式。 + +--- + +## 4. 安全硬约束 + +平台会处理老人、困境妇女、儿童、残疾人、困难家庭等敏感服务信息。 + +必须做到: + +- 默认最小权限; +- 后台 RBAC; +- 资源级权限校验; +- 敏感字段脱敏; +- 操作审计; +- 不允许越权查询; +- 不允许通过 ID 枚举他人服务记录; +- 数据导出必须受控; +- 文件下载必须鉴权; +- 上传文件做类型、大小、扩展名、MIME 校验; +- Token、手机号、身份证件、地址、儿童信息不得写入普通日志; +- 删除业务数据优先软删除/归档,禁止随意物理删除; +- 关键状态变更保留历史; +- 不满 14 周岁相关信息按监护人授权与更严格权限原则处理; +- 不把真实生产数据复制到开发环境; +- 测试数据必须脱敏或生成。 + +--- + +## 5. 可靠性硬约束 + +1. 所有写请求应支持幂等或防重复提交。 +2. 重要状态迁移必须使用数据库事务。 +3. 不允许“接口成功但数据库只写了一半”。 +4. 异步任务失败必须可重试、可查询。 +5. 外部服务失败不能拖垮主业务。 +6. 外部短信、订阅消息、对象存储失败应有降级和补偿。 +7. 服务工单状态必须经过状态机校验,不能任意改字符串。 +8. 每个关键实体保留 `created_at / updated_at / created_by / updated_by`。 +9. 每个关键业务操作有 audit log。 +10. 任何“已完成”都必须有可验证结果,不能只靠前端显示。 + +--- + +## 6. 易维护硬约束 + +- 避免自研基础组件; +- 避免魔法数字; +- 枚举集中管理; +- DTO 与数据库 Entity 分离; +- Controller 不写复杂业务; +- Service 不直接散落第三方 SDK; +- Repository 层负责数据访问; +- 公共错误码统一; +- 日志统一; +- 配置集中; +- 每个模块有 README 或模块说明; +- 复杂逻辑写测试; +- 删除代码前检查是否有业务依赖; +- 依赖升级必须经过测试,不追新版本。 + +--- + +## 7. V1.0 禁止范围 + +除非后续明确批准,V1.0 禁止: + +- 在线公开募捐核心交易系统; +- 复杂社交/朋友圈; +- 即时 IM 聊天系统自研; +- AI 自动作出救助资格决定; +- 人脸识别; +- 未经必要性论证收集身份证完整信息; +- 微服务/K8s; +- 大数据平台; +- 区块链; +- 自研工作流引擎; +- 复杂积分商城; +- 过度游戏化; +- 对外开放 API 平台; +- 多租户 SaaS 架构。 + +--- + +## 8. AI 功能原则 + +AI 是效率工具,不是 V1.0 主角。 + +后续 AI 必须: + +- 默认建议、不自动做高风险决定; +- AI 结果明确标记; +- 工作人员确认后才写入正式业务记录; +- 不把敏感数据发送到未经批准的外部模型; +- 能关闭; +- 业务流程不依赖 AI 才能运行。 + + + +# 02 V1.0 产品需求文档(PRD) + +## 1. V1.0 目标 + +交付可真实投入公益服务的第一版: + +- 群众能找到服务、提交申请、看进度、给反馈; +- 家属/社区/志愿者能代申请; +- 工作人员能受理、建档、派单、留痕、回访; +- 志愿者能注册、报名活动; +- 管理员能管理项目、内容、人员、权限和统计。 + +--- + +## 2. 用户角色 + +### 2.1 PUBLIC + +普通公众。浏览公益项目、活动、组织介绍、参与入口。 + +### 2.2 APPLICANT + +申请人。可以是服务对象本人,也可以是代申请人。 + +### 2.3 BENEFICIARY + +服务对象。老人、困境妇女、困境儿童、残疾人及其他困难群体。 + +### 2.4 VOLUNTEER + +志愿者。 + +### 2.5 STAFF + +公益工作人员。 + +### 2.6 PROJECT_MANAGER + +公益项目负责人。 + +### 2.7 ADMIN + +系统管理员。 + +### 2.8 SUPER_ADMIN + +仅限极少数技术/管理人员,用于系统级配置,日常不使用。 + +--- + +## 3. 小程序信息架构 + +底部 Tab 建议: + +1. 首页 +2. 服务 +3. 公益 +4. 我的 + +### 首页 + +- Logo/品牌语 +- 我需要帮助 +- 帮他人申请 +- 为老服务 +- 妇女帮扶 +- 儿童关爱 +- 残疾人帮扶 +- 最新公益项目 +- 最新活动 +- 我要做公益 + +### 服务 + +- 服务分类 +- 服务申请 +- 我的申请 +- 服务进度 +- 我的反馈 +- 联系乐活 + +### 公益 + +- 公益项目 +- 公益动态 +- 公益活动 +- 志愿者招募 +- 企业/资源合作 +- 乐活公益介绍 + +### 我的 + +根据身份动态显示: + +- 微信登录/绑定手机号 +- 我的资料 +- 我的服务申请 +- 我的活动 +- 志愿者资料 +- 我的志愿服务 +- 消息 +- 隐私设置 +- 联系客服 + +--- + +## 4. 服务申请 + +### 4.1 申请类型 + +- OLD_AGE:老年关爱/为老服务 +- WOMEN:困境妇女 +- CHILD:困境儿童 +- DISABILITY:残疾人帮扶 +- LIFE_DIFFICULTY:生活困难 +- OTHER:其他 + +### 4.2 申请身份 + +- SELF:本人申请 +- FAMILY:家属代申请 +- COMMUNITY:社区/机构代申请 +- VOLUNTEER:志愿者代申请 +- OTHER:其他 + +### 4.3 最小字段 + +- 服务类别 +- 申请人与服务对象关系 +- 服务对象姓名或称呼 +- 联系方式 +- 所在区域(不默认要求精确门牌) +- 需求描述 +- 隐私授权确认 + +### 4.4 可选字段 + +- 图片/附件 +- 期望联系时间 +- 是否行动不便 +- 是否需要上门 +- 紧急程度(用户只能表达,最终等级由工作人员评估) +- 语音描述(二期可增强) + +### 4.5 提交 + +提交后: + +- 生成不可预测的业务编号; +- 显示“已收到”; +- 明确说明后续由工作人员联系; +- 不承诺超出机构能力的即时救援; +- 若用户选择明显紧急风险,应提示联系 110/120 等适当公共紧急服务,但具体文案由运营审核。 + +--- + +## 5. 服务申请状态机 + +```text +SUBMITTED + ↓ +PENDING_CONTACT + ↓ +ASSESSING + ├─→ REJECTED/OUT_OF_SCOPE + ├─→ REFERRED + ↓ +ACCEPTED + ↓ +SERVICE_PLANNED + ↓ +IN_SERVICE + ↓ +SERVICE_COMPLETED + ↓ +FOLLOW_UP_PENDING + ↓ +CLOSED +``` + +可增加: + +- CANCELLED +- SUSPENDED + +规则: + +- 状态变化必须记录历史; +- 不可跳过关键节点,除管理员执行带原因的纠正操作; +- 关闭后再次产生需求,应创建新申请/跟进记录,而不是覆盖旧记录。 + +--- + +## 6. 服务对象档案 + +每个 Beneficiary 一人一档。 + +核心字段: + +- 基础身份 +- 联系信息 +- 联系人/监护人 +- 所属服务类别 +- 地址分级信息 +- 风险/关爱标签 +- 备注 +- 是否重点关爱 +- 档案状态 + +严禁把所有信息塞入一个 JSON 字段。 + +儿童、残疾、健康、家庭困难等敏感字段应按实际必要性分表/分域并控制权限。 + +--- + +## 7. 工单 + +服务申请被接受后创建 Service Case / Work Order。 + +字段: + +- 工单编号 +- beneficiary_id +- application_id +- project_id(可空) +- 服务类别 +- 负责人 +- 计划日期 +- 服务地点 +- 服务说明 +- 当前状态 +- 优先级 +- 完成时间 +- 结果摘要 +- 后续计划 + +工单可关联多个执行人/志愿者,但只有一个责任负责人。 + +--- + +## 8. 服务记录 + +一次工单可有多次服务记录。 + +记录: + +- 服务时间 +- 服务类型 +- 服务人员 +- 服务内容 +- 服务结果 +- 图片附件 +- 服务对象确认情况 +- 后续建议 +- 创建人 + +服务记录创建后若修改,必须保留修订痕迹或审计日志。 + +--- + +## 9. 回访 + +回访任务来源: + +- 服务完成自动创建; +- 重点关爱对象周期任务; +- 工作人员手工创建。 + +回访结果: + +- SATISFIED +- GENERALLY_SATISFIED +- UNSATISFIED +- NEED_MORE_HELP +- UNREACHABLE +- OTHER + +若 `NEED_MORE_HELP`: + +- 必须支持一键创建后续申请/工单; +- 不能只保存文字然后无人处理。 + +--- + +## 10. 志愿者 + +注册字段遵循最小必要: + +- 姓名 +- 手机 +- 所在区域 +- 可服务时间 +- 技能标签 +- 感兴趣领域 +- 紧急联系人(仅确有管理需要时) +- 志愿者状态 + +状态: + +- APPLIED +- APPROVED +- REJECTED +- SUSPENDED +- INACTIVE + +志愿者默认不能查看完整服务对象档案。 + +--- + +## 11. 公益活动 + +活动: + +- 标题 +- 封面 +- 项目 +- 时间 +- 地点 +- 招募人数 +- 报名截止时间 +- 说明 +- 状态 + +状态: + +- DRAFT +- PUBLISHED +- REGISTRATION_CLOSED +- IN_PROGRESS +- COMPLETED +- CANCELLED + +报名: + +- 用户 +- 志愿者身份 +- 报名时间 +- 审核状态 +- 签到状态 +- 服务时长 + +--- + +## 12. 公益项目 + +项目用于: + +- 展示; +- 归集服务工单; +- 统计服务成果; +- 后续政府购买服务项目管理。 + +V1 字段: + +- 项目名称 +- 项目类别 +- 简介 +- 详细介绍 +- 封面 +- 项目周期 +- 状态 +- 是否公开 +- 负责人 +- 目标人数(可空) +- 排序 + +--- + +## 13. 内容管理 + +内容类型: + +- 机构介绍 +- 公益动态 +- 服务故事 +- 通知公告 +- 常见问题 + +内容必须支持草稿和发布,不允许编辑即立即对外。 + +涉及服务对象故事: + +- 必须有内容审核流程; +- 展示前脱敏; +- 未授权不得公开身份性信息。 + +--- + +## 14. 消息通知 + +V1: + +- 站内消息 +- 微信订阅消息(按微信平台能力和用户授权使用) + +事件: + +- 申请提交 +- 申请状态变化 +- 服务安排 +- 服务完成 +- 待评价/回访 +- 活动报名结果 + +通知失败不能回滚主交易。 + +--- + +## 15. Web 后台 + +一级菜单: + +- 工作台 +- 服务申请 +- 服务工单 +- 服务对象 +- 回访管理 +- 公益项目 +- 公益活动 +- 志愿者 +- 内容管理 +- 数据统计 +- 用户与权限 +- 系统设置 +- 审计日志(高权限) + +工作台至少展示: + +- 新申请 +- 待联系 +- 评估中 +- 服务中 +- 待回访 +- 今日服务 +- 超期任务 +- 重点关爱提醒 + +--- + +## 16. 数据统计 V1 + +统计以真实业务数据计算: + +- 申请数 +- 受理数 +- 完成服务数 +- 服务人次 +- 去重服务对象数 +- 各服务类别数量 +- 志愿者数量 +- 活动数量 +- 志愿服务时长 + +不得用手工填写数字覆盖真实统计结果。 + + + +# 03 技术架构 + +## 1. 总体选择 + +优先采用“TypeScript 全栈 + 模块化单体 + MySQL + Redis + 对象存储 + Docker”。 + +推荐: + +### 微信小程序 + +- 微信小程序原生框架 +- TypeScript +- TDesign Miniprogram(优先复用成熟组件) +- 自建 API Client +- 不使用重量级跨端框架作为 V1 默认方案 + +### 管理后台 + +- Vue 3 +- TypeScript +- Vite +- Pinia +- Vue Router +- TDesign Vue Next 或成熟国内 UI 组件库 +- ECharts(仅统计图表需要时) + +### API + +- Node.js LTS +- NestJS +- TypeScript +- REST API +- Prisma 或 TypeORM 二选一;建议 Prisma,但一旦项目启动后不得混用 +- OpenAPI/Swagger +- Zod/class-validator 统一选一种 DTO 校验体系,NestJS 默认可使用 class-validator + +### 数据 + +- MySQL 8.x +- Redis 7.x +- 对象存储:腾讯云 COS 为生产默认实现 +- 本地开发:MinIO 兼容 S3 API + +### 部署 + +- Docker +- Docker Compose +- Nginx +- 腾讯云 CVM / 轻量云服务器可作为早期部署载体 +- 数据库生产优先托管 MySQL;预算有限时也可独立数据库容器,但必须有可靠备份 +- HTTPS +- 国内域名和备案按实际上线要求办理 + +--- + +## 2. 为什么不做微服务 + +当前机构规模和 V1 业务复杂度不需要微服务。 + +模块化单体优点: + +- 部署简单; +- 成本低; +- 调试简单; +- 事务一致性容易保证; +- Codex 更容易保持全局一致; +- 后续真有规模需求时可按模块拆分。 + +模块边界仍必须清晰。 + +--- + +## 3. API 模块建议 + +```text +auth +users +profiles +beneficiaries +service-applications +service-cases +service-records +follow-ups +projects +activities +volunteers +content +files +notifications +statistics +audit +permissions +system +``` + +每模块: + +- controller +- service +- repository +- dto +- entities/model +- policy/permission +- tests + +--- + +## 4. 分层规则 + +```text +Controller + ↓ +Application Service + ↓ +Domain Rules / Policy + ↓ +Repository / Adapter + ↓ +Database / External Service +``` + +禁止: + +- Controller 直接访问 ORM; +- 页面直接写 SQL; +- 业务层直接调用 COS/微信 SDK; +- 前端自己判断权限代替后端校验。 + +--- + +## 5. 外部 Adapter + +必须封装: + +- WeChatAuthAdapter +- WeChatSubscribeMessageAdapter +- ObjectStorageAdapter +- SmsAdapter(若以后使用) +- AIAdapter(二期) +- GeocodingAdapter(二期) + +这样可更换供应商。 + +--- + +## 6. API 规范 + +统一前缀: + +`/api/v1` + +响应: + +```json +{ + "success": true, + "data": {}, + "requestId": "..." +} +``` + +错误: + +```json +{ + "success": false, + "error": { + "code": "APPLICATION_NOT_FOUND", + "message": "未找到该服务申请" + }, + "requestId": "..." +} +``` + +HTTP 状态码必须正确使用,不得永远 200。 + +--- + +## 7. 认证 + +### 小程序端 + +- wx.login 获取 code; +- 后端换取微信身份; +- 服务端创建自己的 session/token; +- token 轮换/过期; +- 手机号绑定按微信开放能力和实际授权流程实现; +- openid/unionid 绝不作为前端授权依据。 + +### 后台 + +推荐: + +- 用户名/手机号 + 强密码; +- 生产可增加 MFA; +- HttpOnly Secure Cookie 优先; +- 管理后台不把长期 Token 放 localStorage。 + +--- + +## 8. 权限 + +RBAC + 资源级 Policy。 + +示例: + +- STAFF 可以看自己负责的工单; +- PROJECT_MANAGER 可以看自己项目范围; +- ADMIN 可跨项目; +- VOLUNTEER 只能查看分配给自己的最小必要任务信息; +- 普通小程序用户只能查看自己提交或合法关联的申请。 + +--- + +## 9. 状态机 + +服务申请、工单、活动、志愿者审批必须采用明确状态机。 + +所有 transition: + +- 验证当前状态; +- 验证目标状态; +- 验证权限; +- 事务写入; +- 写 history; +- 写 audit; +- 事务提交后再触发通知。 + +--- + +## 10. 幂等 + +以下必须防重复: + +- 服务申请提交 +- 活动报名 +- 状态变更 +- 服务记录提交 +- 回访提交 +- 文件关联 +- 微信回调(若未来有) + +方案: + +- clientRequestId / idempotencyKey +- 数据库唯一索引 +- 事务 +- 必要时 Redis 短锁 + +--- + +## 11. 可观测性 + +必须: + +- requestId +- structured logging +- error tracking +- health endpoint +- readiness/liveness +- 慢接口日志 +- 数据库慢查询 +- 关键任务状态 + +日志中禁止敏感数据。 + +--- + +## 12. 备份 + +至少: + +- MySQL 每日自动备份; +- 保留 7/30 天策略可配置; +- 对象存储版本/生命周期按预算配置; +- 每季度至少做一次恢复演练; +- 恢复步骤写入 `infra/backup/README.md`。 + +备份成功≠可恢复,必须验证恢复。 + + + +# 04 数据模型 + +## 1. 通用字段规范 + +核心表统一包含: + +- id: bigint/uuid(二选一后固定;建议 bigint 内部主键 + public_id 随机ID) +- public_id: varchar,外部暴露,禁止连续可枚举 +- created_at +- updated_at +- created_by(后台业务表适用) +- updated_by +- deleted_at(需要软删除的表) + +时间统一数据库 UTC 存储,展示按 Asia/Shanghai。 + +--- + +## 2. 核心实体 + +### users + +微信/后台统一用户主体,避免重复身份系统。 + +- id +- public_id +- status +- user_type +- mobile(加密/脱敏策略) +- nickname +- avatar_url +- last_login_at + +### wechat_identities + +- user_id +- openid +- unionid nullable +- appid +- created_at + 唯一索引 `(appid, openid)`。 + +### staff_profiles + +- user_id +- employee_no nullable +- display_name +- department +- status + +### roles / permissions / user_roles / role_permissions + +标准 RBAC。 + +--- + +## 3. beneficiary_profiles + +服务对象档案: + +- id +- public_id +- display_name +- gender nullable +- birth_year nullable +- mobile nullable +- region_code +- address_detail_encrypted nullable +- primary_category +- key_care_flag +- status +- notes_encrypted nullable +- created_by +- timestamps + +不要默认收集完整身份证。 + +--- + +## 4. beneficiary_contacts + +- beneficiary_id +- name +- relation +- mobile +- is_guardian +- is_emergency_contact +- consent_record_id nullable + +--- + +## 5. beneficiary_tags / tags + +标签: + +- 独居 +- 高龄 +- 行动不便 +- 定期探访 +- 困境儿童 + 等。 + +标签只能帮助服务,不应成为歧视性画像。 + +--- + +## 6. service_applications + +- id +- public_id +- application_no +- applicant_user_id nullable +- beneficiary_id nullable +- apply_mode +- service_category +- description +- region_code +- contact_name +- contact_mobile +- preferred_contact_time nullable +- urgency_self_report nullable +- evaluated_priority nullable +- status +- assigned_staff_id nullable +- source +- submitted_at +- accepted_at nullable +- closed_at nullable +- rejection_reason nullable +- referral_note nullable +- timestamps + +索引: + +- application_no unique +- status +- assigned_staff_id + status +- created_at +- service_category + created_at + +--- + +## 7. service_application_status_history + +- application_id +- from_status +- to_status +- operator_user_id +- reason +- created_at + +不可更新,只追加。 + +--- + +## 8. service_cases + +- id +- public_id +- case_no +- application_id +- beneficiary_id +- project_id nullable +- owner_staff_id +- category +- priority +- status +- planned_start_at nullable +- planned_end_at nullable +- service_location_encrypted nullable +- summary +- result_summary nullable +- follow_up_plan nullable +- completed_at nullable +- timestamps + +--- + +## 9. case_assignees + +支持多人协作: + +- case_id +- user_id +- assignee_type STAFF/VOLUNTEER +- role +- assigned_at +- removed_at nullable + +--- + +## 10. service_records + +- id +- public_id +- case_id +- beneficiary_id +- service_start_at +- service_end_at nullable +- service_type +- content +- result +- follow_up_suggestion nullable +- beneficiary_confirm_status nullable +- created_by +- timestamps + +--- + +## 11. follow_up_tasks + +- id +- public_id +- beneficiary_id +- case_id nullable +- owner_staff_id +- due_at +- follow_up_type +- status +- result_code nullable +- result_note nullable +- completed_at nullable +- source +- timestamps + +索引: + +- owner_staff_id + status + due_at +- beneficiary_id + due_at + +--- + +## 12. volunteers / volunteer_skills + +volunteers: + +- user_id +- real_name +- mobile +- region_code +- availability_note +- status +- approved_by nullable +- approved_at nullable + +skills: + +- volunteer_id +- skill_code + +--- + +## 13. projects + +- id +- public_id +- name +- category +- summary +- content +- cover_file_id +- manager_user_id +- start_date +- end_date +- target_people nullable +- status +- is_public +- sort_order +- timestamps + +--- + +## 14. activities + +- id +- public_id +- project_id nullable +- title +- summary +- content +- cover_file_id +- start_at +- end_at +- registration_deadline +- location_text +- capacity nullable +- status +- published_at nullable +- timestamps + +--- + +## 15. activity_registrations + +- activity_id +- user_id +- status +- registered_at +- checked_in_at nullable +- service_minutes nullable + +唯一 `(activity_id,user_id)` 防重复报名。 + +--- + +## 16. content_posts + +- id +- public_id +- type +- title +- summary +- content +- cover_file_id nullable +- status DRAFT/REVIEW/PUBLISHED/ARCHIVED +- author_user_id +- reviewer_user_id nullable +- published_at nullable +- timestamps + +--- + +## 17. files + +只保存元数据: + +- id +- public_id +- storage_provider +- bucket +- object_key +- original_filename +- mime_type +- size +- sha256 +- business_type +- uploader_user_id +- status +- created_at + +前端不得直接拼接永久公网 URL。 + +--- + +## 18. consents + +- id +- public_id +- user_id nullable +- beneficiary_id nullable +- consent_type +- version +- granted_by +- granted_at +- revoked_at nullable +- evidence_json(仅保存必要证明) +- ip_hash nullable +- created_at + +--- + +## 19. audit_logs + +只追加: + +- id +- request_id +- actor_user_id +- actor_role +- action +- resource_type +- resource_public_id +- result +- metadata_json(不得含敏感明文) +- ip_hash +- user_agent_summary +- created_at + +--- + +## 20. notifications / notification_deliveries + +通知实体与发送记录分开,支持重试和追踪。 + +--- + +## 21. 数据库规则 + +1. 必须使用 FK 或由 ORM/服务保证等价完整性;禁止无约束“凭感觉关联”。 +2. 唯一业务编号必须 unique。 +3. 常查字段建组合索引。 +4. 禁止对大文本字段滥建索引。 +5. 金额如未来出现用 decimal,禁止 float。 +6. 所有枚举在 contracts 中有单一事实来源。 +7. migration 不允许 destructive change 直接上线。 +8. 删除列采用“停用 → 迁移 → 验证 → 后续版本删除”。 + + + +# 05 API 与业务流程规范 + +## 1. API 命名 + +REST 为主。 + +示例: + +- POST `/api/v1/auth/wechat/login` +- GET `/api/v1/me` +- POST `/api/v1/service-applications` +- GET `/api/v1/service-applications/:publicId` +- POST `/api/v1/admin/service-applications/:publicId/transition` +- POST `/api/v1/admin/service-cases` +- POST `/api/v1/admin/service-cases/:publicId/records` +- POST `/api/v1/admin/follow-ups/:publicId/complete` + +不使用: + +- `/getAllXXX` +- `/doSomething2` +- `/updateStatusDirectly` + +--- + +## 2. 服务申请提交事务 + +伪代码: + +```text +validate input +check idempotency key +resolve applicant user +create or leave beneficiary unlinked +create service_application +create initial status history +create audit log +commit +enqueue notification +return public_id + application_no +``` + +禁止在事务中等待第三方消息服务。 + +--- + +## 3. 受理 + +工作人员: + +1. 打开申请; +2. 权限检查; +3. 联系服务对象; +4. 评估; +5. 可关联已有档案或建立档案; +6. ACCEPTED; +7. 创建 service_case; +8. 指定负责人; +9. 创建后续任务; +10. 通知用户。 + +“关联已有档案”必须防止同名误合并,至少依据手机号+人工确认等多个条件。 + +--- + +## 4. 服务执行 + +- 工单 `SERVICE_PLANNED → IN_SERVICE` +- 新增 service_record +- 上传附件 +- 可多次服务 +- 完成时填写 result_summary +- `IN_SERVICE → SERVICE_COMPLETED` +- 自动生成 follow_up_task + +--- + +## 5. 回访 + +完成回访时: + +- 写结果; +- 标记任务完成; +- 如果需要继续帮助,创建 linked application/case; +- 记录来源关系; +- 写 audit。 + +--- + +## 6. 文件上传 + +推荐流程: + +1. 客户端申请上传凭证; +2. 后端检查业务权限和文件约束; +3. 生成短期上传签名或后端代理上传; +4. 上传完成后回调/确认; +5. 后端校验 object metadata; +6. files 表记录; +7. 业务表关联 file_id。 + +安全约束: + +- 文件大小限制; +- MIME 白名单; +- 扩展名白名单; +- object key 随机; +- 禁止使用原始文件名作路径; +- 图片需要时进行安全转码; +- 下载使用短时签名 URL; +- 私有 Bucket 默认。 + +--- + +## 7. 分页 + +统一 cursor 或 page/pageSize。V1 可使用: + +- page >= 1 +- pageSize 默认 20,最大 100 + +返回: + +```json +{ + "items": [], + "page": 1, + "pageSize": 20, + "total": 120 +} +``` + +--- + +## 8. 搜索 + +后台允许按: + +- 业务编号 +- 姓名/称呼 +- 脱敏手机号后四位(视实现) +- 服务类别 +- 状态 +- 时间 +- 项目 +- 负责人 + +敏感全文搜索能力应谨慎开放。 + +--- + +## 9. 导出 + +任何导出: + +- 必须权限校验; +- 记录审计; +- 限制数据范围; +- 大数据量异步生成; +- 文件有过期时间; +- 默认脱敏; +- 导出儿童等敏感信息需要更高权限。 + +--- + +## 10. 错误码 + +至少: + +- AUTH_REQUIRED +- FORBIDDEN +- RESOURCE_NOT_FOUND +- VALIDATION_ERROR +- DUPLICATE_REQUEST +- INVALID_STATE_TRANSITION +- APPLICATION_NOT_FOUND +- CASE_NOT_FOUND +- VOLUNTEER_NOT_APPROVED +- ACTIVITY_FULL +- REGISTRATION_CLOSED +- FILE_TYPE_NOT_ALLOWED +- FILE_TOO_LARGE +- RATE_LIMITED +- INTERNAL_ERROR + +用户端错误文案必须友好,日志保留 requestId 便于排障。 + + + +# 06 安全、隐私与合规基线 + +> 本文是工程基线,不替代正式法律意见。上线前应由运营主体根据实际收集信息、服务范围和微信平台规则完成隐私政策、用户协议和必要合规审核。 + +## 1. 数据分类 + +### L1 公开 + +公益项目公开介绍、公开活动、组织联系方式。 + +### L2 内部 + +普通内部运营信息、非敏感统计。 + +### L3 敏感业务 + +服务申请详情、家庭困难、住址、联系方式、服务记录。 + +### L4 高敏感 + +儿童个人信息、监护信息、健康/残疾相关详细信息、证件、特殊风险记录。 + +默认 L3/L4 不得公开。 + +--- + +## 2. 最小收集 + +每个字段进入系统前必须回答: + +- 为什么需要? +- 不收集是否仍能提供服务? +- 保存多久? +- 谁能看? +- 是否可脱敏? +- 是否真的需要精确地址/证件? + +没有明确目的不得新增收集项。 + +--- + +## 3. 未成年人 + +不满 14 周岁相关个人信息必须采用更严格保护: + +- 明确监护人角色; +- 记录同意; +- 限制访问人员; +- 默认不公开案例身份; +- 不允许志愿者看到与任务无关的信息; +- 定期审计访问; +- 允许合法更正、删除/撤回等请求按规则处理。 + +--- + +## 4. 加密 + +传输: + +- 全站 HTTPS/TLS。 + +存储: + +- 云盘/数据库基础加密按环境开启; +- 极敏感字段应用层加密; +- 密钥不与数据库同库; +- 密钥通过云 KMS/Secret Manager 或安全环境变量管理。 + +密码: + +- Argon2id 或 bcrypt 强哈希; +- 禁止 MD5/SHA1 直接存密码。 + +--- + +## 5. 手机号和地址 + +显示: + +- 普通列表默认脱敏,例如 138****1234; +- 详细查看必须权限。 + +查询: + +- 如需精确匹配可存 HMAC/hash 辅助索引; +- 明文/密文设计需避免为搜索而降低保护。 + +--- + +## 6. 日志 + +禁止记录: + +- 身份证完整号码 +- 完整手机号 +- openid +- access token +- session token +- 详细住址 +- 儿童档案内容 +- 服务描述全文 +- 上传文件原文 + +允许: + +- public_id +- requestId +- action code +- status +- 耗时 +- 脱敏 actor 信息 + +--- + +## 7. OWASP 基线 + +必须防: + +- SQL injection +- XSS +- CSRF(后台 Cookie 认证时) +- SSRF +- IDOR/BOLA +- path traversal +- unrestricted upload +- mass assignment +- brute force +- credential stuffing +- rate abuse +- insecure direct object reference + +每个资源 API 后端都做授权,不相信前端按钮是否隐藏。 + +--- + +## 8. 限流 + +按场景设置: + +- 登录 +- 验证码 +- 服务申请 +- 文件上传 +- 搜索 +- 导出 +- 微信登录 exchange + +不能只按 IP,必要时组合 user/device/IP。 + +--- + +## 9. 审计事件 + +至少审计: + +- 登录成功/失败 +- 角色权限修改 +- 查看高敏感档案 +- 新增/修改服务对象 +- 服务申请状态变化 +- 工单分配 +- 服务记录修改 +- 导出 +- 删除/归档 +- 隐私同意变化 +- 系统配置变化 + +--- + +## 10. 数据保留 + +不要永久保留所有数据。 + +必须由运营方定义: + +- 服务档案保存期限; +- 未成年人数据期限; +- 日志期限; +- 导出文件期限; +- 已取消授权后的处理; +- 项目验收要求。 + +程序上把保留策略做成可配置任务,而不是散落代码。 + +--- + +## 11. 权限矩阵核心原则 + +### Volunteer + +只能: + +- 看自己被分配任务; +- 看完成服务所需的最小信息; +- 提交服务记录; + 不能: +- 搜索全体服务对象; +- 导出名单; +- 看完整历史档案。 + +### Staff + +按负责范围访问。 + +### Project Manager + +按项目访问。 + +### Admin + +跨项目业务管理,但高敏感导出仍可要求额外权限。 + +### Super Admin + +系统维护,不等于默认业务数据无限查看;高敏感读取同样审计。 + +--- + +## 12. 安全发布门禁 + +上线前必须: + +- npm/pnpm dependency audit +- secret scan +- SAST 基础扫描 +- API 权限测试 +- 越权测试 +- 文件上传测试 +- SQL 注入/XSS 测试 +- 弱密码/暴力登录测试 +- 备份恢复测试 +- staging 冒烟测试 + +发现 Critical/High 安全问题不得上线。 + + + +# 07 UI/UX 与适老化规范 + +## 1. 视觉原则 + +关键词: + +- 温暖 +- 可信 +- 简洁 +- 清楚 +- 不商业化 +- 不花哨 + +不得做: + +- 密集 Banner +- 过多卡片 +- 首页十几个同级入口 +- 小字号说明堆叠 +- 复杂渐变和强动画 +- 倒计时/诱导点击式设计 + +--- + +## 2. 小程序首页优先级 + +P0: + +1. 我需要帮助 +2. 帮他人申请 + +P1:3. 四大服务类别 4. 我的服务进度 + +P2:5. 公益项目 6. 公益活动 7. 我要做公益 + +宣传内容不能挤压服务入口。 + +--- + +## 3. 适老规范 + +建议基线: + +- 正文字号尽量 >= 16px 等效视觉; +- 主要按钮文字 >= 17px 等效视觉; +- 主要点击区域高度约 48px 以上; +- 按钮间距足够; +- 一屏只做一个主要决定; +- 错误提示紧贴字段; +- 表单支持保存草稿(较长时); +- 不用颜色作为唯一状态表达; +- 图标同时配文字; +- 返回动作清晰; +- 网络失败可重试; +- 提交后明确“已成功”。 + +具体像素可在设计系统中统一,不以本建议替代微信设备适配。 + +--- + +## 4. 表单原则 + +一页最多 5~7 个核心输入。 +复杂申请拆步骤: + +1. 需要什么帮助 +2. 帮谁申请 +3. 联系方式与地区 +4. 需求描述 +5. 确认提交 + +可选信息放“补充信息”。 + +--- + +## 5. 文案 + +使用: + +- “我需要帮助” +- “帮家人申请” +- “工作人员正在联系您” +- “还需要继续帮助” +- “重新提交” + +避免: + +- “创建工单” +- “状态流转” +- “Beneficiary” +- “Record Not Found” +- “非法参数” + +--- + +## 6. 空状态 + +例如没有服务申请: +“您还没有提交过服务申请。如果您或家人需要帮助,可以从这里开始。” + +提供明确按钮。 + +--- + +## 7. 错误恢复 + +提交失败不能清空已填表单。 +上传失败允许单文件重试。 +网络失败保持本地草稿。 +重复点击提交不应生成多个申请。 + +--- + +## 8. 管理后台 + +后台目标:让工作人员每天打开工作台就知道做什么。 + +表格: + +- 默认显示业务必要列; +- 手机、地址默认脱敏; +- 常用筛选固定; +- 详情采用清晰分区; +- 状态颜色+文字; +- 危险操作二次确认并说明影响。 + +禁止为了“企业感”做过密 Dashboard。 + +--- + +## 9. 无障碍 + +- 合理对比度; +- 图片提供语义说明(可用场景); +- 表单 label 明确; +- 键盘操作兼容后台; +- focus 可见; +- 不用仅 hover 才出现核心操作。 + + + +# 08 工程开发规则 + +## 1. Monorepo + +推荐 pnpm workspace。 + +根命令: + +```bash +pnpm install +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +必须保证 CI 与本地命令一致。 + +--- + +## 2. 包管理 + +- 锁定 package manager 版本; +- 提交 lockfile; +- 不混 npm/yarn/pnpm; +- 国内开发可配置可靠 npm 镜像,但源码不得依赖私有个人镜像; +- CI 应支持官方源回退。 + +--- + +## 3. TypeScript + +- strict: true +- 禁止大面积 any +- 禁止 `@ts-ignore` 掩盖错误;特殊情况写原因 +- API contracts 明确类型 +- 日期统一 ISO 8601 + +--- + +## 4. Git + +分支: + +- main +- develop(可选) +- feature/* +- fix/* +- chore/* + +Commit 建议 Conventional Commits。 + +每个 PR: + +- 变更摘要 +- 测试结果 +- 截图(UI) +- migration 说明 +- 风险 +- 回滚方案 + +--- + +## 5. 环境变量 + +`.env.example` 只能放占位符。 + +示例: + +```env +APP_ENV=development +DATABASE_URL= +REDIS_URL= +WECHAT_APP_ID= +WECHAT_APP_SECRET= +COS_BUCKET= +COS_REGION= +COS_SECRET_ID= +COS_SECRET_KEY= +SESSION_SECRET= +FIELD_ENCRYPTION_KEY= +``` + +真实密钥不得入 Git。 + +--- + +## 6. 数据库 Migration + +规则: + +- 每次 schema 修改一份 migration; +- migration 可审查; +- 生产不允许 ORM 自动 `synchronize=true`; +- 大表变更考虑锁; +- destructive migration 分阶段。 + +--- + +## 7. 代码质量 + +函数: + +- 单一职责; +- 复杂度高就拆; +- 业务规则命名表达含义; +- 不写 1000 行 service; +- 不复制粘贴权限逻辑。 + +必须有: + +- lint +- format +- typecheck +- unit test +- integration test + +--- + +## 8. 错误处理 + +后端: + +- 业务错误使用统一 Domain/Application Error; +- 未预期错误不把 stack 返回用户; +- requestId 贯穿。 + +前端: + +- 业务错误直接友好提示; +- 网络错误可重试; +- 401 统一重新登录; +- 403 明确无权限; +- 5xx 提供 requestId。 + +--- + +## 9. 第三方依赖 + +新增依赖前: + +1. 是否真有必要? +2. 是否长期维护? +3. License 是否可用? +4. 是否显著增大 bundle? +5. 是否国内网络可获取? +6. 能否用现有依赖实现? + +避免“为了 10 行代码引入一个包”。 + +--- + +## 10. Codex 禁止行为 + +- 不读文档直接重构; +- 看到旧代码就全量重写; +- 为通过测试删除测试; +- 用 mock 替代真实功能后宣称完成; +- 注释掉权限; +- 关闭类型检查; +- `catch {}` 吞错误; +- 生产环境 hardcode; +- 把敏感值写 fixtures; +- 删除 migration 历史; +- 改 API 不更新前端和 contracts; +- 自己决定产品删减。 + +--- + +## 11. Definition of Done + +一个任务只有同时满足以下条件才算完成: + +- 功能实现; +- 正常路径测试; +- 异常路径测试; +- 权限测试; +- 数据 migration 完成; +- 文档更新; +- lint 通过; +- typecheck 通过; +- test 通过; +- build 通过; +- 无新 High/Critical 漏洞; +- `10_TASK_STATE.md` 已回写。 + + + +# 09 测试与验收 + +## 1. 测试金字塔 + +### Unit + +业务规则、状态机、权限策略、计算。 + +### Integration + +API + DB,重点测试事务、唯一约束、权限和状态流转。 + +### E2E + +关键业务闭环。 + +--- + +## 2. P0 E2E 场景 + +### E2E-001 本人申请 + +用户登录 → 选择老年服务 → 填写需求 → 提交 → 获得编号 → 我的申请可见。 + +### E2E-002 防重复 + +用户连续点击提交两次 → 只能产生一个业务申请。 + +### E2E-003 代申请 + +家属为老人申请 → 正确保存申请人与服务对象关系。 + +### E2E-004 工作人员受理 + +工作人员登录 → 查看待处理 → 联系/评估 → 建档 → 受理 → 创建工单。 + +### E2E-005 服务执行 + +负责人 → 开始服务 → 添加两条服务记录 → 完成 → 自动生成回访任务。 + +### E2E-006 回访继续帮扶 + +回访选择“需要继续帮助” → 生成关联后续任务/申请,原历史不丢失。 + +### E2E-007 越权 + +志愿者 A 访问志愿者 B 的工单 → 403。 + +### E2E-008 用户越权 + +用户 A 猜测用户 B 的 public_id → 404/403,不泄露存在性。 + +### E2E-009 活动报名 + +报名 → 重复报名被唯一约束阻止 → 人数达到上限后不可继续报名。 + +### E2E-010 文件 + +允许图片上传;exe/php/html 等危险类型拒绝;超限拒绝;私有文件无授权无法下载。 + +--- + +## 3. 安全验收 + +必须测试: + +- IDOR +- broken access control +- mass assignment +- SQLi +- stored/reflected XSS +- CSRF(后台) +- upload bypass +- rate limit +- brute force +- token expiry +- session fixation +- sensitive log leakage + +--- + +## 4. 可靠性验收 + +模拟: + +- Redis 不可用; +- COS 暂时失败; +- 微信消息发送失败; +- 数据库瞬时错误; +- 客户端重复请求; +- 网络超时。 + +要求: + +- 主业务不产生半状态; +- 可重试; +- 失败可追踪; +- UI 不假成功。 + +--- + +## 5. 性能目标(V1 建议基线) + +不是大型互联网项目,不盲目追极限。 + +建议: + +- 常规 API P95 < 500ms(不含第三方和大文件) +- 后台列表首屏合理数据量 < 2s +- 小程序首页优先内容快速可见 +- 100 并发常规请求下无明显错误率上升 +- 数据库常用列表避免 N+1 + +具体目标可根据云资源实测调整。 + +--- + +## 6. UAT 验收人员 + +至少模拟: + +- 65+ 老年服务对象 +- 家属代申请 +- 普通工作人员 +- 项目负责人 +- 志愿者 +- 管理员 + +UAT 不只让开发人员自测。 + +--- + +## 7. V1 上线门槛 + +必须全部满足: + +- P0 E2E 全通过; +- 无 Critical/High 已知安全漏洞; +- 备份成功; +- 恢复演练通过; +- staging 走完整闭环; +- 隐私政策/授权页面就绪; +- 微信小程序审核所需材料就绪; +- 监控和告警正常; +- 可回滚上一版本。 + + + +# 10 任务状态(Codex 每次必须维护) + +> 这是项目“长记忆”的核心文件之一。禁止删除历史,仅更新状态并添加记录。 + +## 当前阶段 + +`PHASE_0 - 仓库初始化与技术基线` + +## 当前版本目标 + +`V1.0 MVP:跑通公益服务主闭环` + +--- + +## 阶段任务 + +### Phase 0:工程初始化 + +- [ ] 建立 monorepo +- [ ] 建立 miniprogram +- [ ] 建立 admin-web +- [ ] 建立 api +- [ ] 建立 shared contracts +- [ ] Docker Compose 本地环境 +- [ ] MySQL / Redis / MinIO +- [ ] ESLint / Prettier / TSConfig +- [ ] CI +- [ ] health check +- [ ] 环境变量模板 +- [ ] AGENTS.md + +### Phase 1:认证与权限 + +- [ ] 微信登录 +- [ ] 后台登录 +- [ ] user +- [ ] RBAC +- [ ] resource policy +- [ ] audit baseline + +### Phase 2:服务申请 + +- [ ] 服务分类 +- [ ] 本人申请 +- [ ] 代申请 +- [ ] 我的申请 +- [ ] 状态历史 +- [ ] 防重复提交 +- [ ] 后台申请列表/详情 + +### Phase 3:服务对象与工单 + +- [ ] beneficiary 档案 +- [ ] 联系人/监护人 +- [ ] 标签 +- [ ] 工单 +- [ ] 分配工作人员 +- [ ] 志愿者协作 +- [ ] 服务记录 +- [ ] 附件 + +### Phase 4:回访闭环 + +- [ ] 回访任务 +- [ ] 自动创建 +- [ ] 完成回访 +- [ ] 继续帮扶 +- [ ] 工作台待办 + +### Phase 5:公益项目/活动/志愿者 + +- [ ] 项目 CRUD/发布 +- [ ] 活动 +- [ ] 活动报名 +- [ ] 志愿者申请/审核 +- [ ] 服务时长 + +### Phase 6:内容与通知 + +- [ ] 公益动态 +- [ ] 机构介绍 +- [ ] 内容审核状态 +- [ ] 站内消息 +- [ ] 微信订阅通知 Adapter + +### Phase 7:统计、安全与上线 + +- [ ] 基础统计 +- [ ] 导出 +- [ ] 权限扫描 +- [ ] 安全测试 +- [ ] 备份恢复 +- [ ] staging +- [ ] UAT +- [ ] production deployment + +--- + +## 当前正在处理 + +暂无。Codex 第一次执行时填写。 + +--- + +## 最近完成记录 + +格式: + +```text +YYYY-MM-DD +任务: +完成: +测试: +涉及 migration: +文档变化: +遗留问题: +``` + +--- + +## 已知问题 + +首次开发为空。任何未解决问题必须写这里,禁止只留在聊天上下文。 + +--- + +## 下一步 + +从 Phase 0 依次执行。禁止跳到 AI、地图、复杂报表等后续功能。 + + + +# 11 架构与产品决策日志(ADR 简化版) + +> Codex 不得删除已批准决策。新决策追加。 + +## D-001 产品架构 + +状态:ACCEPTED + +决定: +V1 使用“微信小程序 + Web 管理后台 + 单体 API + MySQL + Redis + 对象存储”。 + +原因: +低复杂度、易部署、易维护、适合当前组织规模。 + +--- + +## D-002 后端架构 + +状态:ACCEPTED + +决定: +模块化单体,不采用微服务。 + +触发重新评估条件: + +- 单体部署已形成明确性能瓶颈; +- 团队规模显著扩大; +- 某模块有独立扩容/安全隔离的客观需求。 + +--- + +## D-003 主技术语言 + +状态:ACCEPTED + +决定: +优先 TypeScript 全栈。 + +原因: +前后端共享类型、Codex 开发一致性高、维护人员切换成本较低。 + +--- + +## D-004 数据库 + +状态:ACCEPTED + +决定: +MySQL 作为业务事实数据库。 +Redis 不保存不可恢复的唯一业务事实。 + +--- + +## D-005 文件 + +状态:ACCEPTED + +决定: +生产文件进入私有对象存储,通过授权临时访问;不持久化在应用容器本地盘。 + +--- + +## D-006 V1 范围 + +状态:ACCEPTED + +决定: +V1 首先实现服务闭环、志愿者、项目活动、内容和基础统计,不做复杂 AI、地图、公开募捐、自研 IM。 + +--- + +## D-007 隐私 + +状态:ACCEPTED + +决定: +个人敏感信息采取最小收集、最小权限、默认脱敏、操作审计;儿童相关信息更严格。 + +--- + +## D-008 UI + +状态:ACCEPTED + +决定: +优先普通群众与适老使用,不追求炫技。 + +--- + +## 待确认建议 + +Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施: + +```text +[PENDING] +日期: +建议: +原因: +收益: +风险: +迁移成本: +需要用户确认: +``` + + + +# 12 可直接交给 Codex 的总指令 + +你现在是“乐活公益·暖心服务平台”的主开发工程师、架构维护者和测试负责人。 + +你的目标不是一次生成大量代码,而是在整个项目周期中持续、准确、可验证地完成系统。 + +## 一、第一原则 + +在写任何代码之前,必须完整阅读 `docs/` 中的项目文档,特别是: + +- 00_README_FIRST.md +- 01_PROJECT_CONSTITUTION.md +- 10_TASK_STATE.md +- 11_DECISIONS.md + +项目文档是当前事实来源。聊天上下文只能补充,不能覆盖已批准规则,除非用户明确要求修改。 + +## 二、记忆协议 + +每次任务开始: + +1. 读取项目开发宪法; +2. 读取当前任务状态; +3. 读取决策日志; +4. 检查 Git diff; +5. 确定任务属于哪个 Phase; +6. 输出本次实现范围,不扩大范围。 + +每次任务结束: + +1. 运行 lint; +2. 运行 typecheck; +3. 运行 unit tests; +4. 运行 integration tests; +5. 运行 build; +6. 需要时运行 E2E; +7. 更新 `10_TASK_STATE.md`; +8. 更新 `11_DECISIONS.md`(如有); +9. 更新 API/DB 文档; +10. 汇报“完成内容 / 未完成内容 / 测试结果 / 风险 / 下一步”。 + +如果上下文不足: +优先从仓库文档和代码中恢复,而不是凭空假设。 + +## 三、禁止行为 + +你绝对不能: + +- 擅自改产品方向; +- 删除业务需求来降低开发难度; +- 重写整个项目只因为你更喜欢另一套技术; +- 擅自换数据库; +- 擅自改认证方案; +- 跳过权限; +- 跳过 migration; +- 跳过测试; +- 用 mock 冒充生产功能; +- 写死生产密钥; +- 把敏感信息写日志; +- 把所有业务逻辑堆在 Controller; +- 让前端权限判断代替后端鉴权; +- 使用连续 ID 作为面向用户的资源标识; +- 对服务对象数据做无授权公开; +- 开发未批准的 AI 自动决策能力。 + +## 四、编码优先级 + +顺序永远是: + +1. 正确性 +2. 安全 +3. 数据一致性 +4. 用户可理解 +5. 可维护 +6. 可测试 +7. 性能 +8. 开发速度 +9. 技术“先进程度” + +## 五、实现策略 + +不要一次开发所有功能。 + +严格按 `10_TASK_STATE.md` Phase 执行。 + +一个 Phase 的 P0 测试不通过,不进入下一个 Phase。 + +## 六、发生冲突时 + +如果新需求与项目宪法冲突: + +- 不静默修改; +- 说明冲突点; +- 在 `11_DECISIONS.md` 建 PENDING 项; +- 等用户明确批准后再改变。 + +如果是不会改变边界的小实现细节,可自行采用最简单可靠方案,并记录重要决定。 + +## 七、安全要求 + +本项目处理弱势群体和未成年人相关信息。 + +任何时候都按: + +- 最小收集 +- 最小权限 +- 默认脱敏 +- 完整审计 +- 私有文件 +- 后端鉴权 +- 不信任客户端 +- 不信任用户输入 +- 不信任第三方回调 +- 不信任 AI 输出 + +执行。 + +## 八、完成标准 + +“页面能打开”不是完成。 +“接口返回 200”不是完成。 +“我写了代码但没运行测试”不是完成。 + +完成必须符合 `08_ENGINEERING_RULES.md` 的 Definition of Done 和 `09_TEST_ACCEPTANCE.md`。 + +## 九、第一次任务 + +请从 Phase 0 开始: + +1. 初始化 monorepo; +2. 创建 `apps/miniprogram`; +3. 创建 `apps/admin-web`; +4. 创建 `apps/api`; +5. 创建 `packages/contracts`; +6. 建立本地 Docker Compose:MySQL、Redis、MinIO; +7. 建立统一 lint/typecheck/test/build; +8. 创建 `/health`; +9. 创建 `.env.example`; +10. 创建根目录 `AGENTS.md`; +11. 创建 CI; +12. 运行全部命令并修复问题; +13. 更新 `docs/10_TASK_STATE.md`; +14. 不开发业务功能,等待 Phase 1。 + +完成后只汇报: + +- 新增/修改文件 +- 本地启动方式 +- 测试命令和结果 +- 已知问题 +- 下一步 Phase 1 计划 + +不要自行开始 Phase 1。 + + + +# 13 国内环境部署与运维 + +## 1. 目标 + +- 中国大陆访问稳定; +- 微信小程序合法域名可配置; +- TLS; +- 低维护成本; +- 可迁移; +- 可备份; +- 可回滚。 + +--- + +## 2. 推荐生产拓扑(初期) + +```text +微信小程序 + │ HTTPS + ▼ +Nginx / API Domain + │ + ▼ +NestJS API (Docker) + ├── MySQL + ├── Redis + └── Tencent COS + +管理后台 + │ HTTPS + └────────► Nginx / API +``` + +管理后台静态资源可由 Nginx 或对象存储/CDN 托管。 + +--- + +## 3. 云厂商 + +优先腾讯云并非代码强绑定要求,而是微信生态和国内运维便利性考虑。 + +代码必须通过 Adapter 保持: + +- COS 可替换 S3 兼容存储; +- Redis 可替换其他标准 Redis; +- MySQL 可迁移标准 MySQL; +- Nginx/Docker 不绑定专有 PaaS。 + +--- + +## 4. 国内依赖获取 + +开发机可根据实际环境配置: + +- npm 国内镜像; +- Docker 镜像加速; +- 操作系统软件源镜像。 + +但: + +- lockfile 必须提交; +- CI 至少验证标准 registry 可构建; +- 不依赖个人私服; +- 不在代码写死镜像地址。 + +--- + +## 5. 环境 + +### dev + +开发者本机 Docker Compose。 + +### test + +CI 自动化测试。 + +### staging + +与生产拓扑尽量一致,使用测试小程序/测试账号。 + +### prod + +真实用户。 + +严禁 dev 直连 prod 数据库。 + +--- + +## 6. 发布 + +建议: + +1. CI build; +2. 生成 immutable image tag; +3. staging deploy; +4. migration precheck; +5. smoke test; +6. production backup; +7. production deploy; +8. health check; +9. E2E smoke; +10. 观察; +11. 出现严重问题回滚应用版本。 + +数据库 migration 必须设计可前后兼容,避免应用回滚后数据库不兼容。 + +--- + +## 7. 监控 + +至少: + +- API availability +- 5xx rate +- P95 latency +- CPU/memory +- disk +- MySQL connection +- Redis connectivity +- job failures +- backup failures + +告警进入实际有人查看的渠道。 + +--- + +## 8. 容灾 + +初期无需昂贵双活,但必须: + +- 自动数据库备份; +- 异地/云端备份; +- IaC/部署脚本; +- Docker 镜像可重建; +- 文档化恢复; +- 定期演练。 + +--- + +## 9. 微信小程序上线准备 + +实际申请和上线阶段需要根据微信公众平台最新规则核对: + +- 小程序主体 +- 服务类目 +- 隐私保护指引 +- 用户信息/手机号等接口使用说明 +- request/download/upload 合法域名 +- HTTPS 证书 +- 备案/域名要求 +- 订阅消息模板 +- 审核材料 + +微信平台规则可能变化,因此上线前必须重新查官方文档,不得只依赖本文件。 + + + +# 14 发布检查清单 + +## 代码 + +- [ ] main branch clean +- [ ] lockfile committed +- [ ] lint pass +- [ ] typecheck pass +- [ ] unit pass +- [ ] integration pass +- [ ] E2E P0 pass +- [ ] build pass + +## 数据库 + +- [ ] migration reviewed +- [ ] backup done +- [ ] rollback compatibility checked +- [ ] indexes reviewed + +## 安全 + +- [ ] secrets scan +- [ ] dependency scan +- [ ] auth test +- [ ] IDOR test +- [ ] export permission test +- [ ] file upload test +- [ ] logs checked for PII + +## 隐私 + +- [ ] privacy policy version correct +- [ ] consent flow correct +- [ ] children/guardian flow checked +- [ ] sensitive list fields masked +- [ ] retention configuration checked + +## 微信 + +- [ ] legal domains +- [ ] HTTPS +- [ ] privacy declarations +- [ ] API permissions +- [ ] subscribe message handling +- [ ] real device test + +## 运维 + +- [ ] health +- [ ] monitor +- [ ] alert +- [ ] backup +- [ ] restore procedure +- [ ] rollback image available + +## UAT + +- [ ] 本人申请 +- [ ] 代申请 +- [ ] 受理 +- [ ] 建档 +- [ ] 派单 +- [ ] 服务 +- [ ] 回访 +- [ ] 继续帮扶 +- [ ] 志愿者 +- [ ] 活动报名 + + + +# AGENTS.md + +Before coding, read: + +1. docs/00_README_FIRST.md +2. docs/01_PROJECT_CONSTITUTION.md +3. docs/10_TASK_STATE.md +4. docs/11_DECISIONS.md +5. task-related docs + +Rules: + +- Do not change approved product scope or architecture without explicit approval. +- Do not bypass authorization, migrations, tests, or audit requirements. +- Do not log sensitive beneficiary data. +- Do not expose sequential internal IDs. +- Update docs/10_TASK_STATE.md after every task. +- Update docs/11_DECISIONS.md for architecture/product decisions. +- Run lint, typecheck, tests and build before marking work complete.