All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Server-to-server endpoint for the e-Court backend (x-api-key against DOCVER_API_KEY or EXTERNAL_API_KEY): - type=pdf returns the latest version's PDF bytes (404 when absent) - type=docx stamps fresh ticket custom properties (exp + HmacSha256 via addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an in-memory copy before returning — MinIO object is never modified - docId comes from fid inside the ticket_data query param, which must include fid/uid/uname so the stamped file stays redeemable by the add-in at /api/documents/token (buildStampedTicketData drops null values because parseDocverTicket rejects tid:null) - adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey, findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass) - syncs PLAN-DOCVER.md, OpenAPI spec and .env.example Co-Authored-By: Claude Code <noreply@anthropic.com>
301 lines
29 KiB
Markdown
301 lines
29 KiB
Markdown
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||
|
||
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
|
||
> รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService`
|
||
> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`)
|
||
|
||
## 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 <token>` กับทุก 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 เสมอ
|
||
|
||
**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header
|
||
`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties
|
||
ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน
|
||
`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`)
|
||
แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`)
|
||
· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ
|
||
เดียวกัน · body ผิดรูป = 400)
|
||
|
||
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 <access_token>`** —
|
||
ไม่มี 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 เอง |
|
||
| `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 นั้นแทน
|
||
· **breaking change 2026-10-30:** `fields` ถอดออกจาก form เช่นกัน (ส่งมา = 400) —
|
||
`fields` ของ version ถูกแกะจากไฟล์ .docx ที่อัปโหลด**ฝั่ง server** เก็บ**ทั้งก้อน
|
||
เหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}`
|
||
ผ่าน `extractDocumentSnapshot` ของ `lib/lib-tmpl.ts` ตัวเดียวกับ route นั้น — route
|
||
extract เรียก helper ตัวเดียวกัน จึงตรงกันเป๊ะโดยการก่อสร้าง) · `verify.HmacSha256`
|
||
ตรวจด้วย secret ของ add-in ticket ฝั่ง server (env chain `ADDIN_TICKET_SECRET` →
|
||
`SECRET` → `"super_secret"` ของ `lib/addin-ticket.ts` — ต่างจาก extract ที่ใช้ secret
|
||
ของผู้เรียกเพื่อทดสอบ) · เห็น content control ทุกชนิดเสมอ ไม่ขึ้นกับเวอร์ชัน WordApi
|
||
ของ host แบบตอน add-in อ่านเองผ่าน Office.js · ไฟล์ที่แกะไม่ได้ (ไม่มี
|
||
`word/document.xml`/ZIP เสีย/`ticket_data` ไม่ใช่ JSON) = 400 · snapshot เก็บทุกครั้ง
|
||
ที่แกะสำเร็จ (NULL เหลือเฉพาะ row เก่าก่อน 2026-10-30 ที่เก็บ map ดิบ)
|
||
- เอกสาร (`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: { // ก้อนเดียวกับ return ของ /api/template/extract (2026-10-30)
|
||
cc: Record<string, string>; // ค่า Content Control { [tag]: text }
|
||
bookmark: Record<string, string>; // ข้อความใน bookmark { [name]: text }
|
||
properties: { // core/extended/custom props (Date → ISO string)
|
||
standard?: Record<string, string | undefined>;
|
||
extended?: Record<string, number | string | undefined>;
|
||
custom?: Record<string, string | number | boolean | null>;
|
||
};
|
||
verify: Record<string, unknown>; // exp (ฟอร์แมตไทย) + HmacSha256 (secret ฝั่ง server)
|
||
} | null; // null = row เก่าก่อน 2026-10-30 (map ดิบ) หรือไม่มีเลย
|
||
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 ด้วย)
|
||
|
||
### `GET /api/documents/latest?type=docx|pdf&ticket_data=<json>` — ไฟล์ version ล่าสุด (2026-10-31)
|
||
|
||
ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** กลับไปเปิดให้ผู้ใช้ (server-to-server —
|
||
ไม่ใช่ Bearer: auth ด้วย `x-api-key` อย่างเดียว) · `docId` = ฟิลด์ `fid` **ใน ticket_data**
|
||
ไม่ใช่ claim จาก token
|
||
|
||
- auth: header `x-api-key` เทียบ env **`DOCVER_API_KEY` หรือ `EXTERNAL_API_KEY` ตัวใด
|
||
ตัวหนึ่ง** (ต่างจาก /token ที่รับ `DOCVER_API_KEY` อย่างเดียว — ผู้เรียกถือ key สาย N10
|
||
หรือสาย docver ก็ได้) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความ
|
||
เดียวกันเป๊ะ (timing-safe เหมือน `requireDocverApiKey`)
|
||
- query (บังคับทั้งคู่):
|
||
- `type` = `docx` | `pdf` — **ไม่มี default** (ต่างจาก `format` ของ download-link)
|
||
- `ticket_data` = JSON string ของ ticket — **บังคับ `fid` + `uid` + `uname`** (`tid`
|
||
optional, `exp` ถ้าส่งต้องเป็นจำนวนเต็มแต่จะถูกเติมใหม่เสมอ) เพราะ
|
||
`parseDocverTicket` (`utils/docver-ticket.ts`) บังคับทั้งสามตอน add-in เอาไฟล์ไป
|
||
redeem ที่ /token ทาง ticket — validate ตั้งแต่รับเข้า กันคืนไฟล์ที่ add-in เปิด
|
||
ไม่ได้เงียบ ๆ · ⚠️ **หมายเหตุถึงฝั่ง e-Court:** `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่
|
||
ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล) เช่น `{"fid":"DOC-2569-0001","uid":"123",
|
||
"uname":"สมชาย ใจดี"}`
|
||
- **type=pdf** → คืนไบต์ pdf ของ version ล่าสุดตรง ๆ (`Content-Type: application/pdf`) ·
|
||
version ล่าสุดไม่มี pdf = 404 (ข้อความเดียวกับ download-link)
|
||
- **type=docx** → ดึงไบต์ docx จาก MinIO มา **stamp ticket ใหม่บนสำเนาในหน่วยความจำ**
|
||
ก่อนคืน (`buildStampedTicketData` + `stampTicketProps` ของ `lib/lib-tmpl.ts` — recipe
|
||
เดียวกับ `applyTemplate` ของกลุ่ม template: เติม `exp = now + TICKET_SEC` → เรียง key
|
||
→ `HmacSha256` ด้วย secret จาก `addinTicketSecret()` (chain `ADDIN_TICKET_SECRET` →
|
||
`SECRET` → `"super_secret"` — ตรงฝั่ง verify ของ add-in//token ทาง ticket เป๊ะ) →
|
||
`Ed25519` เฉพาะเมื่อ `PRIVATE_KEY_HEX` ตั้ง (ไม่ตั้ง = ไม่ใส่ prop — chain ของ add-in
|
||
verify แค่ HMAC) → `setCustomDocProps` upsert เขียนทับ stamp เดิมได้) —
|
||
**ไม่เขียนกลับ MinIO ตาม invariant #1** (non-destructive) · stamp ล้ม = 500
|
||
(ห้ามกลืนเหมือน `applyTemplate` — ไฟล์ไม่มี stamp = add-in เปิดไม่ได้)
|
||
- ticket_data หลังเติม exp ยาวเกิน **255 ตัวอักษร = 400** (Word ตัดทอนค่า custom
|
||
property ที่ 255 — คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ)
|
||
- response 200: ไบต์ไฟล์ + `Content-Type` ตามชนิด + `Content-Disposition` จาก
|
||
`fileName` ของ version (`contentDispositionValue`) + `Cache-Control: no-store`
|
||
· เช็ค magic bytes ของไบต์ที่อ่านได้ ไม่ตรง = 500 (ความผิดปกติของพื้นที่เก็บ
|
||
ไม่ใช่ความผิดของผู้เรียก)
|
||
|
||
### ตาราง error
|
||
|
||
| เหตุ | สถานะ |
|
||
|---|---|
|
||
| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ multipart · magic bytes ผิด · แกะข้อมูลจากไฟล์ไม่ได้ · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 |
|
||
| `type` ไม่ส่ง/ค่าอื่น · `ticket_data` ไม่ส่ง/ไม่ใช่ JSON object/field นอก allowlist/ขาด fid·uid·uname/เกิน cap/ยาว >2048 · ticket_data หลังเติม exp ยาว >255 (ที่ /latest เฉพาะ type=docx) — ทั้งหมดของ GET /latest | 400 |
|
||
| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) · `x-api-key` ไม่ตั้ง (ทั้งคู่)/ไม่ใส่/ไม่ตรงทั้ง `DOCVER_API_KEY`·`EXTERNAL_API_KEY` (ที่ /latest) | 401 |
|
||
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
|
||
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี (รวม type=pdf ที่ /latest แต่ version ล่าสุดไม่มี pdf) | 404 |
|
||
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) · stamp ticket ล้ม/magic bytes ของ object ไม่ตรง (ที่ /latest) | 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 → **แกะ fields snapshot
|
||
จากไฟล์ทั้งก้อนด้วย `extractDocumentSnapshot` ของ lib/lib-tmpl.ts (ตัวเดียวกับ
|
||
`/api/template/extract` — 2026-10-30)** → 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 ค่าดิบ · GET /latest ใช้ตัวนี้**หรือ** `EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง |
|
||
| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
|
||
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
|
||
| `TICKET_SEC` | `300` | TTL (วินาที) ของ `exp` ที่ stamp ตอน GET /latest type=docx — env เดียวกับกลุ่ม template (`app/api/template/*`) |
|
||
| `PRIVATE_KEY_HEX` | — optional | private key Ed25519 (hex seed) — ตั้งแล้ว GET /latest type=docx จะ stamp prop `Ed25519` เพิ่ม · ไม่ตั้ง = ไม่ใส่ (chain ของ add-in verify แค่ `HmacSha256`) · env เดียวกับกลุ่ม template |
|
||
| `ADDIN_TICKET_SECRET` | chain → `SECRET` → `"super_secret"` | secret ของ HMAC ที่ stamp ที่ /latest — ใช้ `addinTicketSecret()` ให้ตรงฝั่ง verify ของ /token ทาง ticket (ดู `docs/PLAN-ADDIN-AUTH.md`) |
|
||
|
||
`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` + `requireDocumentApiKey` (auth ของ GET /latest — สอง env)
|
||
(pure เหมือนกัน — ไม่ import `@/db`/`minio`)
|
||
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
||
`ensureBucket` + put/remove/presign + `getVersionObjectBytes` (อ่านไบต์ของ /latest) +
|
||
`insertVersionRecord` (tx) + query helpers (`findLatestVersionForDoc` ของ /latest)
|
||
- `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
|
||
- `app/api/documents/latest/route.ts` — GET ไฟล์ version ล่าสุด (2026-10-31 — docx
|
||
stamp ticket บนสำเนาในหน่วยความจำผ่าน `buildStampedTicketData`/`stampTicketProps`
|
||
ของ `lib/lib-tmpl.ts` + `parseTicketDataParam` ของ `utils/docver-ticket.ts` +
|
||
`parseLatestTypeParam` ของ `lib/document-versioning.ts`)
|
||
- `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
|
||
(+ `parseTicketDataParam` ใน `tests/utils/docver-ticket.test.ts` + stamp helpers ใน
|
||
`tests/lib/lib-tmpl.test.ts` ของ /latest)
|
||
|
||
## 8. Out of scope รอบนี้
|
||
|
||
- ~~ไม่แต้ `wordaddin/` หรือ client ใด ๆ~~ (ปลด mock แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง")
|
||
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
|
||
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
|