Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
181 lines
14 KiB
Markdown
181 lines
14 KiB
Markdown
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||
|
||
> วันที่ตกลงแผน: 2026-10-20 · สถานะ: implement รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
|
||
|
||
## 0. บริบท/เป้าหมาย
|
||
|
||
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
|
||
`wordaddin/services/versioningService.ts`):
|
||
|
||
- รับ `docId` จากผู้เรียก → ตารางเอกสารที่ `docId` **ห้ามซ้ำ** (unique index)
|
||
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
|
||
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
||
response)
|
||
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
||
- **endpoint รับ `userId`/`userName` ใน request** เพราะเป็นระบบ stand-alone ไม่มี session
|
||
ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก — identity ส่งมาแบบ plain field)
|
||
- **ไม่มี auth** — เปิดกว้างเหมือนกลุ่ม `api/template` (intranet) · ไม่มี CORS
|
||
|
||
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
||
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
||
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
|
||
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
|
||
|
||
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
|
||
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
|
||
(client-only zone) type define เองใน `db/schema.ts`/`lib`
|
||
|
||
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
||
middleware เหมือน `api/template`)
|
||
|
||
### `POST /api/documents/{docId}/versions` — อัปโหลด version ใหม่
|
||
|
||
multipart/form-data:
|
||
|
||
| field | type | หมายเหตุ |
|
||
|---|---|---|
|
||
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
||
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
||
| `userId` | string | **บังคับ** (stand-alone) — trim 1–100 |
|
||
| `userName` | string | **บังคับ** (stand-alone) — trim 1–200 |
|
||
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
|
||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) |
|
||
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
|
||
| `templateId` | string | optional ≤100 |
|
||
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
|
||
|
||
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10)
|
||
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
|
||
"สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที)
|
||
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
|
||
(= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server**
|
||
(client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
|
||
|
||
### `GET /api/documents/{docId}/versions` — history
|
||
|
||
response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in +
|
||
ส่วนเสริม `createdByUserName`):
|
||
|
||
```ts
|
||
{
|
||
versionId: string; // = id ของ row (uuid)
|
||
versionNumber: number;
|
||
createdAt: string; // ISO-8601
|
||
createdByUserId: string; // = user_id
|
||
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
|
||
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
|
||
fields: Record<string, string> | null;
|
||
message: string | null;
|
||
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
|
||
}
|
||
```
|
||
|
||
- `docId` ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ
|
||
add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
|
||
|
||
### `GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf`
|
||
|
||
- format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400
|
||
- query version row **เช็คทั้ง docId และ versionId พร้อมกัน** (กันโยน id ข้ามเอกสาร) — ไม่เจอ = 404
|
||
- `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
|
||
- response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET**
|
||
(อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ
|
||
ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
|
||
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
|
||
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
|
||
|
||
### ตาราง error
|
||
|
||
| เหตุ | สถานะ |
|
||
|---|---|
|
||
| รูป docId/versionId/format ผิด · field ไม่รู้จัก · ไม่ใช่ multipart · magic bytes ผิด | 400 |
|
||
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
|
||
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
|
||
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
|
||
|
||
## 2. ตาราง (db/schema.ts)
|
||
|
||
```
|
||
documents — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
|
||
document_versions — id uuid PK · document_id uuid FK→documents CASCADE
|
||
· version_number int NOT NULL (+ UNIQUE(document_id, version_number))
|
||
· client_version_id · content_hash NOT NULL · message · fields jsonb
|
||
· template_id · file_name · docx_object_key NOT NULL · pdf_object_key
|
||
· docx_size int NOT NULL · pdf_size int · user_id NOT NULL
|
||
· user_name NOT NULL · created_at
|
||
+ index (document_id, created_at)
|
||
```
|
||
|
||
- **`documents` ≠ `files`** ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน
|
||
(ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
|
||
- `content_hash`/`docx_size` เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน
|
||
insert เสมอ · `hasPdf` **ไม่มี column** — derive จาก `pdf_object_key !== null`
|
||
- object key ใน MinIO: `docver/<versionId>/document.docx` / `document.pdf` — **uuid ล้วน**
|
||
ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · `versionId` สร้าง
|
||
ด้วย `randomUUID()` ฝั่ง server แล้วส่งเป็น `id` ตอน insert (object เขียนไปก่อนแล้ว)
|
||
|
||
## 3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||
|
||
เลือก: **insert-on-conflict + `SELECT … FOR UPDATE` ใน transaction เดียว** (ใช้ `.for("update")`
|
||
ที่ repo ใช้อยู่แล้วใน `app/actions/annotations.ts`):
|
||
|
||
1. `INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING` — สร้างเอกสารถ้ายังไม่มี
|
||
2. `SELECT id FROM documents WHERE doc_id = … FOR UPDATE` — serialize ผู้จ่ายเลขรายเอกสาร
|
||
(unique `doc_id` → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
|
||
3. `SELECT max(version_number) + 1` แล้ว insert row version
|
||
|
||
ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) ·
|
||
`pg_advisory_xact_lock` (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE
|
||
`(document_id, version_number)` เก็บไว้เป็น safety net
|
||
|
||
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า
|
||
|
||
validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId`
|
||
→ **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
|
||
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
|
||
|
||
- crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) —
|
||
**ไม่ทำ GC รอบนี้** บันทึกไว้เป็นงานอนาคต
|
||
- ไม่มี idempotency บน `clientVersionId` — retry หลัง network timeout เกิด version ใหม่
|
||
(client แก้เลขจาก response เองได้) ยอมรับได้
|
||
|
||
## 5. Carve-out จาก invariant #4 (CLAUDE.md)
|
||
|
||
กลุ่ม API นี้ **เขียน DB ผ่าน route handler** (ไม่ใช่ Server Action) และไม่ publish SSE
|
||
ไม่ `revalidatePath` — ตาราง `documents`/`document_versions` เป็นของกลุ่มนี้ล้วน ไม่มีหน้า
|
||
note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ
|
||
read-only ของ N10 แต่ตัวนี้เขียนได้)
|
||
|
||
## 6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)
|
||
|
||
| env | default | หมายเหตุ |
|
||
|---|---|---|
|
||
| `MINIO_ENDPOINT` | — บังคับ | host อย่างเดียว ไม่มี scheme |
|
||
| `MINIO_PORT` | `9000` | dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง) |
|
||
| `MINIO_USE_SSL` | `false` | |
|
||
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
|
||
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
|
||
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
|
||
|
||
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
|
||
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
|
||
ตอนดาวน์โหลด
|
||
|
||
## 7. ไฟล์ของกลุ่มนี้
|
||
|
||
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
|
||
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
|
||
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
|
||
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
||
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
|
||
- `app/api/documents/[docId]/versions/route.ts` — POST + GET
|
||
- `app/api/documents/[docId]/versions/[versionId]/download-link/route.ts` — GET
|
||
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
|
||
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
|
||
- `tests/lib/document-versioning.test.ts` — unit test ส่วน pure
|
||
|
||
## 8. Out of scope รอบนี้
|
||
|
||
- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
|
||
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
|
||
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
|