# PLAN-DOCVER — Document Versioning API (stand-alone) > วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement > รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin) ## 0. บริบท/เป้าหมาย ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน `wordaddin/services/versioningService.ts`): - ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid` (string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร) - แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน response) - เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด - **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22 — เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header `x-api-key` แล้วแนบ `Authorization: Bearer ` กับทุก request — ระบบ stand-alone ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น พาหะ identity เดียว - **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET` (ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts` ของ N10) · ไม่มี 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/token` — ขอ access_token (2026-10-22) auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ JSON body (allowlist — key อื่น = 400 กัน typo): | field | type | หมายเหตุ | |---|---|---| | `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) | | `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) | | `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid | | `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) | - env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า" (ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret) - response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }` — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600) · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe - **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer `** — ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ) ### `POST /api/documents/versions` — อัปโหลด version ใหม่ auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ `docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป multipart/form-data: | field | type | หมายเหตุ | |---|---|---| | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `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) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | - field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) — **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` (= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server** (client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน) ### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token) 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 | null; message: string | null; hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว } ``` - `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) ### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf` - format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400 - query version row **เช็ค docId (จาก fid ใน token) กับ 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 | เหตุ | สถานะ | |---|---| | รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | | `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | | เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | | MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 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//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 | | `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ | | `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | | `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | `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-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer` + `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`) - `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) + `ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers - `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key) - `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) - `app/api/documents/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` + `tests/lib/docver-token.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 ทำ)