Compare commits
5 commits
6daee15851
...
280cc2da40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280cc2da40 | ||
|
|
949861ba86 | ||
|
|
ed8493004c | ||
|
|
765946f0d5 | ||
|
|
5917c0532c |
21 changed files with 3272 additions and 282 deletions
13
.env.example
13
.env.example
|
|
@ -5,3 +5,16 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
|||
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
|
||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
|
||||
# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
|
||||
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
|
||||
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
|
||||
# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ —
|
||||
# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# MINIO_BUCKET=docver
|
||||
# DOCVER_LINK_TTL_SECONDS=900
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -21,7 +21,7 @@
|
|||
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`)
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone บอก userId/userName เองใน request ไม่มี session ของแอป note) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath`
|
||||
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
||||
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
||||
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
||||
|
|
@ -34,12 +34,14 @@
|
|||
## โครงสร้างไฟล์ (ตามแผน)
|
||||
|
||||
```
|
||||
docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket)
|
||||
docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 3 endpoint, ดีไซน์ตาราง, env MINIO_* ทั้งหมด
|
||||
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
||||
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
||||
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
|
||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ผู้เรียกบอก userId/userName เอง ไม่มี session, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||
|
|
@ -47,11 +49,17 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย
|
|||
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม)
|
||||
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
||||
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseDocId (charset [A-Za-z0-9._:-] 1–200), parseVersionUploadForm (allowlist field + userId/userName บังคับ + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม
|
||||
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
|
||||
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
|
||||
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
|
||||
<<<<<<< HEAD
|
||||
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
|
||||
=======
|
||||
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank)
|
||||
>>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a
|
||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
||||
|
|
@ -74,6 +82,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
|
|||
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
||||
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
|
||||
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
|
||||
app/api/documents/[docId]/versions/route.ts + [versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, ไม่มี auth เปิดกว้างเหมือน api/template): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+userId/userName+fields/message/clientVersionId/templateId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath
|
||||
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
||||
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -19,7 +19,7 @@
|
|||
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` |
|
||||
|
||||
## ขั้นตอน Setup จากศูนย์
|
||||
|
||||
|
|
@ -31,17 +31,16 @@ npm install
|
|||
|
||||
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
|
||||
|
||||
### 2. รัน PostgreSQL container
|
||||
### 2. รัน PostgreSQL + MinIO ด้วย docker compose
|
||||
|
||||
```bash
|
||||
docker run -d --name judes-note-db \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
|
||||
-p 54322:5432 \
|
||||
-v judes-note-db-data:/var/lib/postgresql/data \
|
||||
postgres:17-alpine
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
|
||||
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio`
|
||||
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
|
||||
|
||||
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
|
||||
|
||||
### 3. สร้างไฟล์ `.env`
|
||||
|
||||
|
|
@ -50,6 +49,12 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
|||
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2)
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
```
|
||||
|
||||
(เทียบกับ `.env.example`)
|
||||
|
|
@ -125,6 +130,28 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
## API จัด version เอกสาร (docver — stand-alone)
|
||||
|
||||
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
|
||||
|
||||
```bash
|
||||
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
|
||||
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
|
||||
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
|
||||
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
|
||||
|
||||
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
|
||||
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
|
||||
```
|
||||
|
||||
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
|
||||
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
|
||||
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| อาการ | สาเหตุ/ทางแก้ |
|
||||
|
|
@ -132,7 +159,8 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
|
||||
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
|
||||
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
|
||||
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
|
||||
|
||||
## หมายเหตุทางเทคนิค
|
||||
|
|
@ -148,11 +176,14 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
||||
db/index.ts postgres.js + drizzle (prepare: false)
|
||||
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
|
||||
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
|
||||
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
|
||||
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
||||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
|
||||
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf
|
||||
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
||||
// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร
|
||||
import {
|
||||
docverError,
|
||||
parseDocId,
|
||||
parseDownloadFormat,
|
||||
parseVersionIdParam,
|
||||
} from "@/lib/document-versioning";
|
||||
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { docId: string; versionId: string } },
|
||||
) {
|
||||
const docId = parseDocId(params.docId);
|
||||
if (!docId.ok) return docverError(docId.error, 400);
|
||||
const versionId = parseVersionIdParam(params.versionId);
|
||||
if (!versionId.ok) return docverError(versionId.error, 400);
|
||||
let format: string | null = null;
|
||||
try {
|
||||
format = new URL(request.url).searchParams.get("format");
|
||||
} catch {
|
||||
return docverError("URL ของ request ไม่ถูกต้อง", 400);
|
||||
}
|
||||
const parsedFormat = parseDownloadFormat(format);
|
||||
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
|
||||
|
||||
try {
|
||||
const row = await findVersionForDoc(docId.docId, versionId.versionId);
|
||||
if (!row) {
|
||||
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||
}
|
||||
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
|
||||
return docverError("version นี้ไม่มีสำเนา PDF", 404);
|
||||
}
|
||||
const link = await presignVersionUrl(row.id, parsedFormat.format);
|
||||
return new Response(JSON.stringify(link), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: สร้าง download link ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
162
app/api/documents/[docId]/versions/route.ts
Normal file
162
app/api/documents/[docId]/versions/route.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions
|
||||
// กลุ่ม API stand-alone ไม่มี auth (เปิดกว้างเหมือน api/template — intranet) — ผู้เรียกบอก
|
||||
// identity เองผ่าน field userId/userName เพราะไม่มี session ร่วมกับใคร
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { toDocumentVersionDTO } from "@/db/schema";
|
||||
import {
|
||||
docverError,
|
||||
DOCVER_MAX_UPLOAD_BYTES,
|
||||
DOCX_CONTENT_TYPE,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseDocId,
|
||||
parseVersionUploadForm,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
import {
|
||||
findDocumentByDocId,
|
||||
insertVersionRecord,
|
||||
listVersionsByDocId,
|
||||
putVersionObjects,
|
||||
removeVersionObjects,
|
||||
versionObjectKeys,
|
||||
} from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||
* ลำดับ: validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||
export async function POST(request: Request, { params }: { params: { docId: string } }) {
|
||||
const docId = parseDocId(params.docId);
|
||||
if (!docId.ok) return docverError(docId.error, 400);
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น multipart/form-data", 400);
|
||||
}
|
||||
|
||||
const parsed = parseVersionUploadForm(form);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
|
||||
}
|
||||
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
|
||||
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
|
||||
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
|
||||
}
|
||||
|
||||
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
|
||||
const rawPdf = form.get("pdf");
|
||||
let pdf: File | null = null;
|
||||
if (rawPdf instanceof File && rawPdf.size > 0) {
|
||||
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
pdf = rawPdf;
|
||||
}
|
||||
|
||||
let docxBytes: Buffer;
|
||||
let pdfBytes: Buffer | null = null;
|
||||
try {
|
||||
docxBytes = Buffer.from(await file.arrayBuffer());
|
||||
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
|
||||
} catch (err) {
|
||||
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
if (!isDocxBytes(docxBytes)) {
|
||||
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
|
||||
}
|
||||
if (pdfBytes && !isPdfBytes(pdfBytes)) {
|
||||
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
|
||||
const versionId = randomUUID();
|
||||
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
|
||||
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
|
||||
const docxDisplayName =
|
||||
parsed.form.fileName ?? sanitizeFilePartName(file.name);
|
||||
|
||||
try {
|
||||
await putVersionObjects({
|
||||
versionId,
|
||||
docx: { bytes: docxBytes, fileName: docxDisplayName },
|
||||
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
const keys = versionObjectKeys(versionId);
|
||||
let row;
|
||||
try {
|
||||
row = await insertVersionRecord({
|
||||
versionId,
|
||||
docId: docId.docId,
|
||||
clientVersionId: parsed.form.clientVersionId,
|
||||
contentHash,
|
||||
message: parsed.form.message,
|
||||
fields: parsed.form.fields,
|
||||
templateId: parsed.form.templateId,
|
||||
fileName: docxDisplayName,
|
||||
docxObjectKey: keys.docx,
|
||||
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
||||
docxSize: docxBytes.length,
|
||||
pdfSize: pdfBytes?.length ?? null,
|
||||
userId: parsed.form.userId,
|
||||
userName: parsed.form.userName,
|
||||
});
|
||||
} catch (err) {
|
||||
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
||||
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
|
||||
await removeVersionObjects(versionId, pdfBytes !== null);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hasPdf: pdfBytes !== null,
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
||||
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
||||
export async function GET(_request: Request, { params }: { params: { docId: string } }) {
|
||||
const docId = parseDocId(params.docId);
|
||||
if (!docId.ok) return docverError(docId.error, 400);
|
||||
|
||||
try {
|
||||
const doc = await findDocumentByDocId(docId.docId);
|
||||
if (!doc) {
|
||||
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
|
||||
}
|
||||
const rows = await listVersionsByDocId(doc.id);
|
||||
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: โหลด version history ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
94
db/schema.ts
94
db/schema.ts
|
|
@ -253,12 +253,76 @@ export const annotationVersions = pgTable(
|
|||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
||||
);
|
||||
|
||||
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
|
||||
|
||||
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
|
||||
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
|
||||
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
|
||||
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
|
||||
docId: text("doc_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
|
||||
);
|
||||
|
||||
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
|
||||
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
|
||||
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
|
||||
export const documentVersions = pgTable(
|
||||
"document_versions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
documentId: uuid("document_id")
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: "cascade" }),
|
||||
versionNumber: integer("version_number").notNull(),
|
||||
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
|
||||
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
|
||||
clientVersionId: text("client_version_id"),
|
||||
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
|
||||
contentHash: text("content_hash").notNull(),
|
||||
/** commit message (FR-13) — trim แล้วว่าง = NULL */
|
||||
message: text("message"),
|
||||
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
|
||||
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
|
||||
fields: jsonb("fields").$type<Record<string, string>>(),
|
||||
templateId: text("template_id"),
|
||||
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
|
||||
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
|
||||
fileName: text("file_name"),
|
||||
docxObjectKey: text("docx_object_key").notNull(),
|
||||
pdfObjectKey: text("pdf_object_key"),
|
||||
docxSize: integer("docx_size").notNull(),
|
||||
pdfSize: integer("pdf_size"),
|
||||
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
|
||||
userId: text("user_id").notNull(),
|
||||
userName: text("user_name").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
|
||||
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
|
||||
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||||
|
||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||||
export type DocumentRow = typeof documents.$inferSelect;
|
||||
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||||
|
||||
export type AnnotationDTO = {
|
||||
id: string;
|
||||
|
|
@ -371,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
|||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||||
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||||
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||||
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||||
export type DocumentVersionDTO = {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
createdByUserName: string;
|
||||
contentHash: string;
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean;
|
||||
};
|
||||
|
||||
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||||
return {
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
createdByUserId: row.userId,
|
||||
createdByUserName: row.userName,
|
||||
contentHash: row.contentHash,
|
||||
fields: row.fields,
|
||||
message: row.message,
|
||||
hasPdf: row.pdfObjectKey !== null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
|
||||
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
|
||||
#
|
||||
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
|
||||
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
|
||||
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
|
||||
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: judes-note-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
|
||||
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
|
||||
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
|
||||
POSTGRES_DB: ecourt-note
|
||||
ports:
|
||||
- "54322:5432"
|
||||
volumes:
|
||||
- judes-note-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
|
||||
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
|
||||
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
|
||||
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
|
||||
minio:
|
||||
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: judes-note-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
|
||||
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
|
||||
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
|
||||
- "19001:9001" # console ดู bucket/object ผ่าน browser
|
||||
volumes:
|
||||
- judes-note-minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
|
||||
judes-note-db-data:
|
||||
name: judes-note-db-data
|
||||
judes-note-minio-data:
|
||||
name: judes-note-minio-data
|
||||
181
docs/PLAN-DOCVER.md
Normal file
181
docs/PLAN-DOCVER.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# 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 ทำ)
|
||||
29
drizzle/0005_icy_bloodaxe.sql
Normal file
29
drizzle/0005_icy_bloodaxe.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CREATE TABLE "document_versions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"version_number" integer NOT NULL,
|
||||
"client_version_id" text,
|
||||
"content_hash" text NOT NULL,
|
||||
"message" text,
|
||||
"fields" jsonb,
|
||||
"template_id" text,
|
||||
"file_name" text,
|
||||
"docx_object_key" text NOT NULL,
|
||||
"pdf_object_key" text,
|
||||
"docx_size" integer NOT NULL,
|
||||
"pdf_size" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"user_name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"doc_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
|
||||
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");
|
||||
735
drizzle/meta/0005_snapshot.json
Normal file
735
drizzle/meta/0005_snapshot.json
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
{
|
||||
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
|
||||
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.annotation_replies": {
|
||||
"name": "annotation_replies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_replies_annotation_idx": {
|
||||
"name": "annotation_replies_annotation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "annotation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_replies_annotation_id_annotations_id_fk": {
|
||||
"name": "annotation_replies_annotation_id_annotations_id_fk",
|
||||
"tableFrom": "annotation_replies",
|
||||
"tableTo": "annotations",
|
||||
"columnsFrom": [
|
||||
"annotation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotation_versions": {
|
||||
"name": "annotation_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_versions_file_idx": {
|
||||
"name": "annotation_versions_file_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_versions_file_id_files_id_fk": {
|
||||
"name": "annotation_versions_file_id_files_id_fk",
|
||||
"tableFrom": "annotation_versions",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotations": {
|
||||
"name": "annotations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved": {
|
||||
"name": "resolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotations_file_page_idx": {
|
||||
"name": "annotations_file_page_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "page_index",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotations_file_id_files_id_fk": {
|
||||
"name": "annotations_file_id_files_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.bookmarks": {
|
||||
"name": "bookmarks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookmarks_file_id_files_id_fk": {
|
||||
"name": "bookmarks_file_id_files_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.document_versions": {
|
||||
"name": "document_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"document_id": {
|
||||
"name": "document_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_number": {
|
||||
"name": "version_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_version_id": {
|
||||
"name": "client_version_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_hash": {
|
||||
"name": "content_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fields": {
|
||||
"name": "fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"template_id": {
|
||||
"name": "template_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_name": {
|
||||
"name": "file_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_object_key": {
|
||||
"name": "docx_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_object_key": {
|
||||
"name": "pdf_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_size": {
|
||||
"name": "docx_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_size": {
|
||||
"name": "pdf_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"document_versions_document_number_key": {
|
||||
"name": "document_versions_document_number_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "version_number",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"document_versions_document_created_idx": {
|
||||
"name": "document_versions_document_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"document_versions_document_id_documents_id_fk": {
|
||||
"name": "document_versions_document_id_documents_id_fk",
|
||||
"tableFrom": "document_versions",
|
||||
"tableTo": "documents",
|
||||
"columnsFrom": [
|
||||
"document_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"doc_id": {
|
||||
"name": "doc_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"documents_doc_id_key": {
|
||||
"name": "documents_doc_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "doc_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.files": {
|
||||
"name": "files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_url": {
|
||||
"name": "pdf_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type_id": {
|
||||
"name": "type_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"files_type_id_idx": {
|
||||
"name": "files_type_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"files_external_id_key": {
|
||||
"name": "files_external_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "external_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.annotation_type": {
|
||||
"name": "annotation_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"HIGHLIGHT",
|
||||
"DRAW",
|
||||
"COMMENT",
|
||||
"SHAPE",
|
||||
"REDACTION",
|
||||
"TEXTBOX",
|
||||
"STAMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@
|
|||
"when": 1790072946313,
|
||||
"tag": "0004_right_ted_forrester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792467537332,
|
||||
"tag": "0005_icy_bloodaxe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
273
lib/document-versioning.ts
Normal file
273
lib/document-versioning.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
|
||||
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
|
||||
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
|
||||
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||
|
||||
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
|
||||
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
|
||||
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
|
||||
export const DOCVER_MESSAGE_MAX = 2048;
|
||||
|
||||
export const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
/** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก
|
||||
* ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */
|
||||
const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ */
|
||||
const FORM_FIELDS = [
|
||||
"file",
|
||||
"pdf",
|
||||
"userId",
|
||||
"userName",
|
||||
"clientVersionId",
|
||||
"fields",
|
||||
"message",
|
||||
"templateId",
|
||||
"fileName",
|
||||
] as const;
|
||||
|
||||
export type DocverError = { ok: false; error: string };
|
||||
|
||||
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
|
||||
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
|
||||
export function docverError(message: string, status: number): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน
|
||||
* (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */
|
||||
export function parseDocId(raw: string):
|
||||
| { ok: true; docId: string }
|
||||
| DocverError {
|
||||
const docId = raw.trim();
|
||||
if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -",
|
||||
};
|
||||
}
|
||||
return { ok: true, docId };
|
||||
}
|
||||
|
||||
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
|
||||
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
|
||||
export function isDocxBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
|
||||
}
|
||||
|
||||
export function isPdfBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
||||
}
|
||||
|
||||
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
|
||||
export function parseVersionIdParam(raw: string):
|
||||
| { ok: true; versionId: string }
|
||||
| DocverError {
|
||||
const versionId = raw.trim().toLowerCase();
|
||||
if (!UUID_RE.test(versionId)) {
|
||||
return { ok: false, error: "versionId ต้องเป็น UUID" };
|
||||
}
|
||||
return { ok: true, versionId };
|
||||
}
|
||||
|
||||
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
|
||||
export function parseDownloadFormat(raw: string | null):
|
||||
| { ok: true; format: "docx" | "pdf" }
|
||||
| DocverError {
|
||||
const format = raw?.trim() ?? "";
|
||||
if (format === "") return { ok: true, format: "docx" };
|
||||
if (format === "docx" || format === "pdf") return { ok: true, format };
|
||||
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
||||
}
|
||||
|
||||
function trimOrNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
|
||||
function formString(form: FormData, field: string):
|
||||
| { ok: true; value: string | null }
|
||||
| DocverError {
|
||||
const entry = form.get(field);
|
||||
if (entry === null) return { ok: true, value: null };
|
||||
if (typeof entry !== "string") {
|
||||
return { ok: false, error: `field '${field}' ต้องเป็น string` };
|
||||
}
|
||||
return { ok: true, value: trimOrNull(entry) };
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
|
||||
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
|
||||
export function sanitizeDisplayFileName(raw: string | null): string | null {
|
||||
if (raw === null) return null;
|
||||
const base = raw.split(/[\\/]/).pop() ?? raw;
|
||||
return trimOrNull(base)?.slice(0, 200) ?? null;
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
|
||||
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
|
||||
export function sanitizeFilePartName(raw: string): string | null {
|
||||
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||
}
|
||||
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||
export function parseFieldsJson(raw: string):
|
||||
| { ok: true; fields: Record<string, string> | null }
|
||||
| DocverError {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
if (keys.length > 100) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
if (value.length > 10_000) {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
|
||||
export type ParsedVersionUploadForm = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
clientVersionId: string | null;
|
||||
message: string | null;
|
||||
templateId: string | null;
|
||||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||
fileName: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||
* size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */
|
||||
export function parseVersionUploadForm(form: FormData):
|
||||
| { ok: true; form: ParsedVersionUploadForm }
|
||||
| DocverError {
|
||||
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
|
||||
// downlevelIteration — array ธรรมดาไม่ต้อง)
|
||||
for (const key of Array.from(form.keys())) {
|
||||
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const userId = formString(form, "userId");
|
||||
if (!userId.ok) return userId;
|
||||
if (userId.value === null) {
|
||||
return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
|
||||
}
|
||||
if (userId.value.length > 100) {
|
||||
return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const userName = formString(form, "userName");
|
||||
if (!userName.ok) return userName;
|
||||
if (userName.value === null) {
|
||||
return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
|
||||
}
|
||||
if (userName.value.length > 200) {
|
||||
return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const clientVersionId = formString(form, "clientVersionId");
|
||||
if (!clientVersionId.ok) return clientVersionId;
|
||||
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
||||
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const templateId = formString(form, "templateId");
|
||||
if (!templateId.ok) return templateId;
|
||||
if (templateId.value && templateId.value.length > 100) {
|
||||
return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const message = formString(form, "message");
|
||||
if (!message.ok) return message;
|
||||
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
||||
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||
}
|
||||
|
||||
const rawFields = formString(form, "fields");
|
||||
if (!rawFields.ok) return rawFields;
|
||||
let fields: Record<string, string> | null = null;
|
||||
if (rawFields.value !== null) {
|
||||
const parsed = parseFieldsJson(rawFields.value);
|
||||
if (!parsed.ok) return parsed;
|
||||
fields = parsed.fields;
|
||||
}
|
||||
|
||||
const rawFileName = formString(form, "fileName");
|
||||
if (!rawFileName.ok) return rawFileName;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
form: {
|
||||
userId: userId.value,
|
||||
userName: userName.value,
|
||||
clientVersionId: clientVersionId.value,
|
||||
message: message.value,
|
||||
templateId: templateId.value,
|
||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||
fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
|
||||
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
|
||||
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
|
||||
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
|
||||
|
||||
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
|
||||
const ext = `.${kind}`;
|
||||
const ascii = (displayName ?? "")
|
||||
.replace(/[^\x20-\x7E]/g, "")
|
||||
.replace(/["\\]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
let asciiName: string = DEFAULT_FILE_NAMES[kind];
|
||||
if (/[A-Za-z0-9]/.test(ascii)) {
|
||||
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
|
||||
}
|
||||
const display = displayName?.trim();
|
||||
if (display) {
|
||||
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
|
||||
}
|
||||
return `attachment; filename="${asciiName}"`;
|
||||
}
|
||||
275
lib/docver-store.ts
Normal file
275
lib/docver-store.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
|
||||
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
|
||||
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
|
||||
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
|
||||
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
|
||||
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
|
||||
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { Client } from "minio";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
documents,
|
||||
documentVersions,
|
||||
type DocumentRow,
|
||||
type DocumentVersionRow,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
contentDispositionValue,
|
||||
DOCX_CONTENT_TYPE,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "@/lib/document-versioning";
|
||||
|
||||
type MinioConfig = {
|
||||
endPoint: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
|
||||
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
|
||||
function readConfig(): MinioConfig {
|
||||
const endPoint = process.env.MINIO_ENDPOINT?.trim();
|
||||
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
|
||||
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
|
||||
if (!endPoint || !accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
|
||||
);
|
||||
}
|
||||
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
|
||||
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
|
||||
return {
|
||||
endPoint,
|
||||
port: Number.isFinite(port) && port > 0 ? port : 9000,
|
||||
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
|
||||
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
|
||||
ttlSeconds:
|
||||
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
|
||||
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
|
||||
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
|
||||
const globalForMinio = globalThis as unknown as {
|
||||
docverClient?: { key: string; client: Client };
|
||||
docverBucketReady?: { bucket: string; promise: Promise<void> };
|
||||
};
|
||||
|
||||
function getClient(): { client: Client; config: MinioConfig } {
|
||||
const config = readConfig();
|
||||
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
|
||||
if (globalForMinio.docverClient?.key !== key) {
|
||||
globalForMinio.docverClient = {
|
||||
key,
|
||||
client: new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { client: globalForMinio.docverClient.client, config };
|
||||
}
|
||||
|
||||
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
|
||||
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
|
||||
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
|
||||
async function ensureBucket(client: Client, bucket: string): Promise<void> {
|
||||
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
|
||||
const promise = (async () => {
|
||||
if (await client.bucketExists(bucket)) return;
|
||||
try {
|
||||
await client.makeBucket(bucket);
|
||||
} catch (err) {
|
||||
if (!(await client.bucketExists(bucket))) throw err;
|
||||
}
|
||||
})();
|
||||
globalForMinio.docverBucketReady = { bucket, promise };
|
||||
promise.catch(() => {
|
||||
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
|
||||
if (globalForMinio.docverBucketReady?.promise === promise) {
|
||||
globalForMinio.docverBucketReady = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return globalForMinio.docverBucketReady.promise;
|
||||
}
|
||||
|
||||
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
|
||||
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
|
||||
return {
|
||||
docx: `docver/${versionId}/document.docx`,
|
||||
pdf: `docver/${versionId}/document.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
export type PutVersionInput = {
|
||||
versionId: string;
|
||||
docx: { bytes: Buffer; fileName: string | null };
|
||||
pdf?: { bytes: Buffer; fileName: string | null };
|
||||
};
|
||||
|
||||
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
|
||||
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
|
||||
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
|
||||
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
|
||||
const { client, config } = getClient();
|
||||
await ensureBucket(client, config.bucket);
|
||||
const keys = versionObjectKeys(input.versionId);
|
||||
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
|
||||
"Content-Type": DOCX_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
|
||||
});
|
||||
if (!input.pdf) return;
|
||||
try {
|
||||
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
|
||||
"Content-Type": PDF_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
|
||||
});
|
||||
} catch (err) {
|
||||
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
|
||||
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
|
||||
export async function removeVersionObjects(
|
||||
versionId: string,
|
||||
hadPdf: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { client, config } = getClient();
|
||||
const keys = versionObjectKeys(versionId);
|
||||
await client.removeObject(config.bucket, keys.docx);
|
||||
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
|
||||
} catch (err) {
|
||||
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
|
||||
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
|
||||
* คำนวณ local ล้วนไม่มี network call */
|
||||
export async function presignVersionUrl(
|
||||
versionId: string,
|
||||
format: "docx" | "pdf",
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
const { client, config } = getClient();
|
||||
const key = versionObjectKeys(versionId)[format];
|
||||
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
|
||||
return {
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
|
||||
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
|
||||
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, documentId))
|
||||
.orderBy(desc(documentVersions.versionNumber));
|
||||
}
|
||||
|
||||
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
|
||||
export async function findVersionForDoc(
|
||||
docId: string,
|
||||
versionId: string,
|
||||
): Promise<DocumentVersionRow | null> {
|
||||
const [row] = await db
|
||||
.select({ version: documentVersions })
|
||||
.from(documentVersions)
|
||||
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
|
||||
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
|
||||
.limit(1);
|
||||
return row?.version ?? null;
|
||||
}
|
||||
|
||||
export type InsertVersionInput = {
|
||||
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
|
||||
versionId: string;
|
||||
docId: string;
|
||||
clientVersionId: string | null;
|
||||
contentHash: string;
|
||||
message: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
templateId: string | null;
|
||||
fileName: string | null;
|
||||
docxObjectKey: string;
|
||||
pdfObjectKey: string | null;
|
||||
docxSize: number;
|
||||
pdfSize: number | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
|
||||
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
|
||||
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
|
||||
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(documents)
|
||||
.values({ docId: input.docId })
|
||||
.onConflictDoNothing({ target: documents.docId });
|
||||
|
||||
const [doc] = await tx
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.docId, input.docId))
|
||||
.for("update");
|
||||
if (!doc) {
|
||||
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
|
||||
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
|
||||
}
|
||||
|
||||
const [latest] = await tx
|
||||
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, doc.id));
|
||||
|
||||
const [row] = await tx
|
||||
.insert(documentVersions)
|
||||
.values({
|
||||
id: input.versionId,
|
||||
documentId: doc.id,
|
||||
versionNumber: (latest?.max ?? 0) + 1,
|
||||
clientVersionId: input.clientVersionId,
|
||||
contentHash: input.contentHash,
|
||||
message: input.message,
|
||||
fields: input.fields,
|
||||
templateId: input.templateId,
|
||||
fileName: input.fileName,
|
||||
docxObjectKey: input.docxObjectKey,
|
||||
pdfObjectKey: input.pdfObjectKey,
|
||||
docxSize: input.docxSize,
|
||||
pdfSize: input.pdfSize,
|
||||
userId: input.userId,
|
||||
userName: input.userName,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
243
lib/openapi.ts
243
lib/openapi.ts
|
|
@ -1,5 +1,6 @@
|
|||
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API
|
||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
|
||||
// (จัด version เอกสาร)
|
||||
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
|
||||
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
|
||||
//
|
||||
|
|
@ -23,8 +24,8 @@ export const openApiSpec = {
|
|||
title: "Document Template & External API ของระบบ Judge's Note",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control) " +
|
||||
"และ API ภายนอกสำหรับ backend e-Court (N10)",
|
||||
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
|
||||
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
|
||||
},
|
||||
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
|
||||
tags: [
|
||||
|
|
@ -34,6 +35,13 @@ export const openApiSpec = {
|
|||
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
|
||||
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
|
||||
},
|
||||
{
|
||||
name: "documents",
|
||||
description:
|
||||
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
|
||||
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — ไม่มี auth (stand-alone): " +
|
||||
"ผู้เรียกบอก userId/userName เองใน multipart",
|
||||
},
|
||||
{
|
||||
name: "Judge's Note",
|
||||
description:
|
||||
|
|
@ -217,6 +225,193 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/{docId}/versions": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
|
||||
description:
|
||||
"สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " +
|
||||
".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " +
|
||||
"คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " +
|
||||
"optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response)",
|
||||
parameters: [
|
||||
{
|
||||
name: "docId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "รหัสเอกสารจากผู้เรียก (add-in GUID / รหัส e-Court) — [A-Za-z0-9._:-]",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file", "userId", "userName"],
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "ไฟล์ .docx ของ version นี้ (บังคับ)",
|
||||
},
|
||||
pdf: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description:
|
||||
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
description: "รหัสผู้อัปโหลด (ระบบ stand-alone ไม่มี session)",
|
||||
},
|
||||
userName: {
|
||||
type: "string",
|
||||
description: "ชื่อผู้อัปโหลด (แสดงใน version history)",
|
||||
},
|
||||
clientVersionId: {
|
||||
type: "string",
|
||||
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
||||
},
|
||||
fields: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}',
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
||||
},
|
||||
templateId: {
|
||||
type: "string",
|
||||
description: "รหัส template ที่ใช้สร้างเอกสาร (ถ้ามี)",
|
||||
},
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "version ถูกบันทึกแล้ว",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/UploadVersionResult" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
413: {
|
||||
description: "ไฟล์แนบใหญ่เกิน 100MB",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
get: {
|
||||
tags: ["documents"],
|
||||
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
|
||||
description:
|
||||
"version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " +
|
||||
"message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in",
|
||||
parameters: [
|
||||
{
|
||||
name: "docId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "รหัสเอกสารจากผู้เรียก — [A-Za-z0-9._:-]",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "รายการ version เรียงใหม่ → เก่า",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { $ref: "#/components/schemas/VersionRecord" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
404: {
|
||||
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/{docId}/versions/{versionId}/download-link": {
|
||||
get: {
|
||||
tags: ["documents"],
|
||||
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
|
||||
description:
|
||||
"คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " +
|
||||
"เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " +
|
||||
"ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404",
|
||||
parameters: [
|
||||
{
|
||||
name: "docId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "รหัสเอกสาร (ต้องตรงกับ version นี้ — โยน id ข้ามเอกสารไม่ได้)",
|
||||
},
|
||||
{
|
||||
name: "versionId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", format: "uuid" },
|
||||
description: "id ของ version (field versionId ใน VersionRecord)",
|
||||
},
|
||||
{
|
||||
name: "format",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["docx", "pdf"], default: "docx" },
|
||||
description: "ชนิดไฟล์ที่จะดาวน์โหลด (ไม่ส่ง = docx)",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "presigned URL พร้อมเวลาหมดอายุ",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/DownloadLink" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
404: {
|
||||
description:
|
||||
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
|
@ -407,6 +602,48 @@ export const openApiSpec = {
|
|||
createdAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
UploadVersionResult: {
|
||||
type: "object",
|
||||
description: "ตอบกลับหลังอัปโหลด version สำเร็จ",
|
||||
properties: {
|
||||
hasPdf: { type: "boolean", description: "true = version นี้มีสำเนา PDF" },
|
||||
versionId: { type: "string", format: "uuid" },
|
||||
versionNumber: {
|
||||
type: "integer",
|
||||
description: "เลข version ที่ server จ่าย (ค่าจริง — ไม่ใช่ที่ client คาด)",
|
||||
},
|
||||
},
|
||||
},
|
||||
VersionRecord: {
|
||||
type: "object",
|
||||
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
|
||||
properties: {
|
||||
versionId: { type: "string", format: "uuid" },
|
||||
versionNumber: { type: "integer" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
createdByUserId: { type: "string" },
|
||||
createdByUserName: { type: "string" },
|
||||
contentHash: {
|
||||
type: "string",
|
||||
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
|
||||
},
|
||||
fields: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
message: { type: "string", nullable: true, description: "commit message" },
|
||||
hasPdf: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
DownloadLink: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "presigned URL ของ MinIO (GET โดยตรง)" },
|
||||
expiresAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export function middleware(req: NextRequest) {
|
|||
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
||||
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
|
||||
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
|
||||
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
|
||||
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
|
||||
|
|
@ -35,6 +37,6 @@ export function middleware(req: NextRequest) {
|
|||
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!note/login|api/note|api/template|api/swagger|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||
"/((?!note/login|api/note|api/template|api/documents|api/swagger|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
654
package-lock.json
generated
654
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
"@xmldom/xmldom": "^0.9.12",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "^14.2.35",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
|
|
|
|||
459
pnpm-lock.yaml
generated
459
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
allowBuilds:
|
||||
'@azure/msal-node-extensions': set this to true or false
|
||||
'@microsoft/m365agentstoolkit-cli': set this to true or false
|
||||
'@scarf/scarf': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
keytar: set this to true or false
|
||||
File diff suppressed because one or more lines are too long
244
tests/lib/document-versioning.test.ts
Normal file
244
tests/lib/document-versioning.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DOCVER_MESSAGE_MAX,
|
||||
contentDispositionValue,
|
||||
docverError,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseDocId,
|
||||
parseDownloadFormat,
|
||||
parseFieldsJson,
|
||||
parseVersionIdParam,
|
||||
parseVersionUploadForm,
|
||||
sanitizeDisplayFileName,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
|
||||
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
|
||||
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
|
||||
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
|
||||
|
||||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
||||
describe("parseDocId", () => {
|
||||
it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => {
|
||||
for (const raw of [
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
"ecourt:123",
|
||||
"DOC_2026.10",
|
||||
" doc1 ",
|
||||
"a".repeat(200),
|
||||
]) {
|
||||
const result = parseDocId(raw);
|
||||
ok(result);
|
||||
expect(result.docId).toBe(raw.trim());
|
||||
}
|
||||
});
|
||||
|
||||
it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => {
|
||||
for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) {
|
||||
expect(parseDocId(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionIdParam", () => {
|
||||
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
|
||||
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
|
||||
ok(result);
|
||||
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
|
||||
});
|
||||
|
||||
it("ตกเมื่อไม่ใช่รูป UUID", () => {
|
||||
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
|
||||
expect(parseVersionIdParam(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDownloadFormat", () => {
|
||||
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
|
||||
for (const raw of [null, "", " ", " docx "]) {
|
||||
const result = parseDownloadFormat(raw);
|
||||
ok(result);
|
||||
expect(result.format).toBe("docx");
|
||||
}
|
||||
});
|
||||
|
||||
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
|
||||
const pdf = parseDownloadFormat("pdf");
|
||||
ok(pdf);
|
||||
expect(pdf.format).toBe("pdf");
|
||||
for (const raw of ["exe", "PDF", "docxx"]) {
|
||||
expect(parseDownloadFormat(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDocxBytes / isPdfBytes", () => {
|
||||
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
|
||||
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
|
||||
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
|
||||
});
|
||||
|
||||
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
|
||||
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
|
||||
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
|
||||
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFieldsJson", () => {
|
||||
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
|
||||
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
|
||||
ok(result);
|
||||
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
|
||||
const empty = parseFieldsJson("{}");
|
||||
ok(empty);
|
||||
expect(empty.fields).toBeNull();
|
||||
});
|
||||
|
||||
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
|
||||
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
|
||||
expect(parseFieldsJson(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionUploadForm", () => {
|
||||
function baseForm(): FormData {
|
||||
const form = new FormData();
|
||||
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
||||
form.append("userId", "u0001");
|
||||
form.append("userName", "สมชาย ใจดี");
|
||||
return form;
|
||||
}
|
||||
|
||||
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
|
||||
const form = baseForm();
|
||||
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
||||
form.append("message", " ");
|
||||
form.append("templateId", "tmpl-main");
|
||||
form.append("fields", '{"blackno":"1"}');
|
||||
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
||||
const result = parseVersionUploadForm(form);
|
||||
ok(result);
|
||||
expect(result.form).toEqual({
|
||||
userId: "u0001",
|
||||
userName: "สมชาย ใจดี",
|
||||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||||
message: null,
|
||||
templateId: "tmpl-main",
|
||||
fileName: "คำสั่งเรียก.docx",
|
||||
fields: { blackno: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
|
||||
const form = baseForm();
|
||||
form.append("oops", "1");
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("oops");
|
||||
});
|
||||
|
||||
it("ตกเมื่อขาด userId หรือ userName (stand-alone บอกผู้อัปโหลดเอง)", () => {
|
||||
const noUser = baseForm();
|
||||
noUser.delete("userId");
|
||||
expect(parseVersionUploadForm(noUser).ok).toBe(false);
|
||||
const noName = baseForm();
|
||||
noName.delete("userName");
|
||||
expect(parseVersionUploadForm(noName).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||||
const form = baseForm();
|
||||
form.delete("userId");
|
||||
form.append("userId", new File(["x"], "u.txt"));
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("userId");
|
||||
});
|
||||
|
||||
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
||||
const edge = baseForm();
|
||||
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
|
||||
ok(parseVersionUploadForm(edge));
|
||||
const over = baseForm();
|
||||
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
|
||||
expect(parseVersionUploadForm(over).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
|
||||
const form = baseForm();
|
||||
form.append("fields", '{"a":1}');
|
||||
expect(parseVersionUploadForm(form).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
||||
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
|
||||
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
|
||||
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
|
||||
expect(sanitizeDisplayFileName(" ")).toBeNull();
|
||||
expect(sanitizeDisplayFileName(null)).toBeNull();
|
||||
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
|
||||
const original = "คำสั่งเรียกเอกสาร.docx";
|
||||
const mangled = Buffer.from(original, "utf8").toString("latin1");
|
||||
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
|
||||
expect(sanitizeFilePartName(mangled)).toBe(original);
|
||||
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
|
||||
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docverError", () => {
|
||||
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
|
||||
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDispositionValue", () => {
|
||||
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
|
||||
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
|
||||
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
|
||||
});
|
||||
|
||||
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
|
||||
expect(contentDispositionValue("Report Final", "docx")).toBe(
|
||||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
|
||||
);
|
||||
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
|
||||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
|
||||
);
|
||||
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
|
||||
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
|
||||
);
|
||||
});
|
||||
|
||||
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
|
||||
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
|
||||
'attachment; filename="document.docx"; filename*=UTF-8\'\''
|
||||
+ encodeURIComponent("คำสั่งเรียก"),
|
||||
);
|
||||
});
|
||||
|
||||
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
|
||||
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
|
||||
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
|
||||
+ encodeURIComponent("รายงาน 2026"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue