# PAC Platform

PAC — Patient Analyze Center. Independent service that ingests patient facts from host systems
(Friday SaaS / 5i5y / DW / mock), maintains versioned Persona + Follow-up Plan models, and
exposes API Pool 2 query surfaces back to hosts. See [`docs/architecture.md`](docs/architecture.md)
for the full design rationale.

## Layout

```
apps/
  pac-service/   NestJS backend (PostgreSQL + Redis, JWT auth, three pools)
  pac-web/       Next.js (App Router) workbench, loaded via iframe
packages/
  types/         API/model/enum types shared by service and web
  utils/         Crypto, date, template helpers
docs/architecture.md
docker-compose.yml  Local dev (postgres / redis / service / web)
```

## Architecture in one screen

```
API Pool 1 (in)            Pool 2 (out)
─────────────              ────────────
POST /pac/v1/facts    →    GET  /pac/v1/plans?view=pool
POST /pac/v1/sync/         GET  /pac/v1/plans/:id
     trigger                POST /pac/v1/plans/:id/assign | recycle | executions
                            GET  /pac/v1/patients/:id/timeline
                            GET  /pac/v1/patients/:id/persona

Layers (DB):
  PatientFactEvent  — append-only timeline (single source of truth)
  Persona/Feature   — versioned, feature + description + evidence
  FollowupPlan      — versioned per (patient, scenario), the recall pool
  PlanExecution     — 接口 2; outcomes never feed back into facts

Adapters (sync engine):
  PullAdapter / PushAdapter — direction-based, mock impls included
```

## Quick start

```bash
# 1. install
pnpm install

# 2. start postgres + redis (apps will pick up apps/*/.env automatically)
cp apps/pac-service/.env.example apps/pac-service/.env
cp apps/pac-web/.env.example apps/pac-web/.env
docker compose up -d postgres redis

# 3. run the database migration + seed the demo platform
pnpm --filter @pac/service prisma:generate
pnpm --filter @pac/service prisma:migrate
pnpm --filter @pac/service prisma:seed
#   → seeds appId=demo-app / appSecret=demo-secret

# 4. dev (parallel start of service + web)
pnpm dev
```

- Backend:        http://localhost:3101
- API reference:  http://localhost:3101/api/docs   (Scalar)
- OpenAPI JSON:   http://localhost:3101/api/openapi.json
- Frontend:       http://localhost:3100

## Smoke test the auth flow

```bash
# 1) host backend trades app credentials for a recall_token + one-time code
curl -s -X POST http://localhost:3101/pac/v1/auth/token \
  -H 'content-type: application/json' \
  -d '{
    "appId": "demo-app",
    "appSecret": "demo-secret",
    "user": {
      "userId": "u-1",
      "tenantId": "demo-tenant",
      "recallRole": "leader",
      "clinicIds": ["clinic-a", "clinic-b"]
    }
  }'

# 2) front-end (or curl) trades the code for the recall_token
curl -s -X POST http://localhost:3101/pac/v1/auth/exchange-code \
  -H 'content-type: application/json' \
  -d '{ "code": "<paste code from step 1>" }'

# 3) call a protected endpoint — list the recall pool (empty until plan engine runs)
curl -s "http://localhost:3101/pac/v1/plans?view=pool" \
  -H "authorization: Bearer <recall_token>"

# 4) drive the pipeline end-to-end:
curl -s -X POST http://localhost:3101/pac/v1/sync/trigger \
  -H "authorization: Bearer <recall_token>" -H 'content-type: application/json' \
  -d '{}'        # mock-pull emits ~10 facts → patient_fact_events filled in
```

To preview the workbench against a fresh code, point the iframe at:
`http://localhost:3100/?code=<one-time-code>`.

## Scripts

| Command | Effect |
|---|---|
| `pnpm dev` | Parallel `nest start --watch` + `next dev` via Turborepo |
| `pnpm build` | Topologically build all packages then apps |
| `pnpm type-check` | Workspace-wide `tsc --noEmit` |
| `pnpm lint` | ESLint across packages |
| `pnpm db:migrate` | `prisma migrate dev` |
| `pnpm db:studio` | Open Prisma Studio |

## Adding shadcn/ui components

```bash
cd apps/pac-web
pnpm dlx shadcn@latest add input table dialog tabs
```

## API response contract

Every endpoint (except `/health` and SSE streams) returns HTTP 200 with a wrapped envelope:

```jsonc
// success
{ "code": 0, "msg": "ok", "data": { /* payload */ } }

// failure
{ "code": 10101, "msg": "appId 或 appSecret 不正确", "data": null,
  "details": [ /* optional structured detail (e.g. zod issues, drift sample) */ ] }
```

HTTP 5xx is reserved for genuine server crashes (DB down, uncaught panic) so load
balancers / k8s probes still detect dead instances.

### Code numbering (5-digit segmented `A-BB-CC`)

| A | 来源 | BB | 模块 | CC | 模块内顺序 |
|---|---|---|---|---|---|
| `0` | 成功 | `00` 通用 | `01` 顺序号 | | |
| `1` | 客户端错(请求/鉴权/权限/资源) | `01` auth, `02` platform-admin, `03` patient, `04` persona, `05` plan | | | |
| `2` | 业务规则错(状态冲突等) | (同上模块表) | | | |
| `3` | 第三方/上游错(宿主 API / AI 网关 / DW) | `08` sync, `09` agent | | | |
| `9` | PAC 自身错 | `00` 通用 | | | |

### Currently defined codes

```
0       OK                                    成功

# 1xxxx 客户端错
10001   CLIENT_BAD_REQUEST                    请求格式错
10002   CLIENT_VALIDATION_FAILED              字段校验失败(看 details)
10003   CLIENT_RATE_LIMITED                   限流
10004   CLIENT_NOT_FOUND_GENERIC              资源不存在(无具体 BB 时)
10101   AUTH_INVALID_CREDENTIALS              appId 或 appSecret 错
10102   AUTH_INVALID_CODE                     一次性 code 无效或过期
10103   AUTH_INVALID_REFRESH_TOKEN            refresh token 无效
10104   AUTH_TOKEN_MISSING                    未携带 access token
10105   AUTH_TOKEN_EXPIRED                    access token 过期
10106   AUTH_TOKEN_INVALID                    access token 解析失败
10107   AUTH_PERMISSION_DENIED                权限不足
10201   PLATFORM_NOT_FOUND                    平台不存在
10202   PLATFORM_ALREADY_EXISTS               平台已存在
10301   PATIENT_NOT_FOUND                     患者不存在
10401   PERSONA_NOT_FOUND                     画像不存在
10501   PLAN_NOT_FOUND                        计划不存在

# 2xxxx 业务规则错
20501   PLAN_ALREADY_ASSIGNED                 计划已被分配
20502   PLAN_ALREADY_COMPLETED                计划已完成,不能修改
20503   PLAN_BREAKER_TRIPPED                  联系次数超限,熔断

# 3xxxx 第三方/上游错
30801   SYNC_HOST_UNREACHABLE                 宿主 API 不可达
30802   SYNC_CONTRACT_DRIFT                   字段映射漂移
30803   SYNC_HOST_AUTH_FAILED                 宿主登录失败/凭据失效
30901   AGENT_GATEWAY_TIMEOUT                 AI 网关超时
30902   AGENT_GATEWAY_ERROR                   AI 网关返回错

# 9xxxx PAC 自身错
90000   INTERNAL_ERROR                        服务内部错误(uncaught)
90001   INTERNAL_DB_ERROR                     数据库错误
```

参见 [`packages/types/src/schemas/wrap.ts`](packages/types/src/schemas/wrap.ts) 的 `ApiCode` 常量,
是这张表的源代码。

### 客户端处理范式

```ts
const res = await fetch(...);     // 永远 200(除 5xx 真崩)
if (res.status >= 500) handleServerCrash();
const body = await res.json();
if (body.code !== 0) throw new ApiError(body.code, body.msg, body.details);
return body.data;
```

跟阿里 / 腾讯 / 字节内部 SDK 写法一致,前端工程师无缝接入。

## Database workflow

Schema is the single source of truth at [`apps/pac-service/prisma/schema.prisma`](apps/pac-service/prisma/schema.prisma). Migrations are version-controlled SQL in `prisma/migrations/`. Commit `schema.prisma` + the new migration folder + business code together.

### Dev: change schema → generate migration

```bash
# 1. Edit prisma/schema.prisma
# 2. Generate + apply + regenerate client:
pnpm --filter @pac/service prisma migrate dev --name add_lead_priority

# Teammate pulls and just applies pending migrations:
pnpm --filter @pac/service prisma migrate dev    # no --name = apply only
```

When tweaks pile up locally before any prod deploy, you can squash:

```bash
rm -rf apps/pac-service/prisma/migrations
pnpm --filter @pac/service prisma migrate reset --skip-seed
pnpm --filter @pac/service prisma migrate dev --name init
```

⚠️ Only safe before production has applied any migration. After that, history is append-only.

### Prod: apply only — never `migrate dev`

`docker-compose.prod.yml` includes a one-shot `pac-migrate` job that runs `prisma migrate deploy` and exits. The API service waits for it via `service_completed_successfully`. Idempotent on re-runs.

Manual apply (e.g. running migrations from your laptop against prod):
```bash
DATABASE_URL=postgres://... pnpm --filter @pac/service exec prisma migrate deploy
```

`prisma migrate status` to see drift before deploying.

### Long-running processes — kill before renaming or schema changes

`pnpm dev`, `pnpm db:studio` and `next dev` are all long-runners that hold the old
`cwd`, `.env`, and compiled Prisma client in memory. After renaming directories or
running migrations that change schema, kill stale processes before restarting —
otherwise you get confusing errors (wrong DB user, missing fields, etc.).

```bash
# See what's running and from which path:
ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{for(i=11;i<=NF;i++)printf "%s ",$i; print ""}'

# Nuke them all:
ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null
```

### Backups

Two scripts, different jobs — don't confuse them:

| | |
|---|---|
| `scripts/backup-db.sh` | **ad-hoc / local.** Dump before a risky migration, etc. |
| `deploy/pac-backup.sh` | **the nightly cron on the test host** (`/root/pac-backup.sh`, `0 4 * * *`). Edit it here and copy it up — for months it existed only on the server. |

```bash
./scripts/backup-db.sh                                # ./backups, keep 7
BACKUP_DIR=/var/backups RETENTION=14 ./scripts/backup-db.sh

bash /root/pac-backup.sh --dry-run                    # on the host: check space, delete nothing
```

> ⚠️ **The nightly script rotates *before* it dumps, and refuses to start when space is short.**
> It used to rotate afterwards, so the disk had to hold `KEEP+1` dumps at once — on 2026-08-11
> and 2026-08-15 it filled the disk and Postgres died mid-dump
> (`PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device`).
> The DB recovered on its own once the partial dump was deleted, so **nothing looked wrong by
> daylight** and it happened twice before anyone noticed. Don't reorder those two steps back.

Restore (test in a scratch DB regularly — an unverified backup is no backup):
```bash
docker exec -i pac-postgres pg_restore -U pac -d pac \
  --clean --if-exists < backups/pac-20260507-120000.dump
```

Schedule via cron / launchd. For prod, ship the dump off-host (S3, etc.) — local-only backups die with the host.

### Hand-edit migrations when…

- **Renaming a column** (Prisma generates `DROP + ADD` and loses data → change to `ALTER TABLE … RENAME COLUMN`)
- **Adding an index on a large table** (change `CREATE INDEX` to `CREATE INDEX CONCURRENTLY`)
- **Adding NOT NULL with backfill** (split into 3 steps: add nullable → `UPDATE` → `ALTER` to NOT NULL)

After hand-editing, re-run `prisma migrate dev` against a clean dev DB to confirm it applies cleanly before committing.

## Version notes

| Package | Version | Note |
|---|---|---|
| Node | 22 LTS | NestJS 11 / Next 16 minimum |
| pnpm | 10 | uses `onlyBuiltDependencies` for Prisma postinstall |
| Next.js | 16 | `next lint` removed → flat config + `eslint .` |
| React | 19.2 | |
| NestJS | 11 | |
| Prisma | 6.19 | pinned to last 6.x; v7 is a major refactor (driver adapters + `prisma.config.ts`) — upgrade in a separate sweep when ready |
| TypeScript | 5.9 | TS 6 just shipped; sticking to 5.9 until Nest decorators are confirmed clean on 6 |
| Tailwind | 4.2 | CSS-first config (`@import "tailwindcss"` in `globals.css`, no `tailwind.config.js`) |
| ESLint | 9 | Next 16's plugin defaults to flat config (ESLint 10-ready) |
| Scalar | 1.x | API changed: `apiReference({ content: doc, ... })` (top-level, not `spec.content`) |
