From 765946f0d5f8b093777cfe0ed1f71762cd6d1ada Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Tue, 20 Oct 2026 10:52:35 +0700 Subject: [PATCH] feat(docver): add document versioning API (docx+pdf in MinIO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand-alone API group at /api/documents/{docId}/versions — one document per docId (unique), each version stores .docx + optional .pdf as MinIO objects with uploader userId/userName/createdAt (no auth, like api/template; callers identify themselves per docs/PLAN-DOCVER.md). - POST versions (multipart): validates fields/magic bytes/100MB cap, uploads to MinIO first, cleans up objects if the DB insert fails; returns {hasPdf, versionId, versionNumber} with server-authoritative numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls) - GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId) - GET versions/{versionId}/download-link?format=docx|pdf: presigned URL (docId+versionId matched together; pdf-missing = 404) - tables documents/document_versions + migration drizzle/0005 - docker-compose.yml: judes-note-db + judes-note-minio (quay.io image, host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names) - lib/openapi.ts: documents tag + paths for Swagger UI - middleware: exempt api/documents from login redirect - unit tests tests/lib/document-versioning.test.ts (23 cases) wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL. Co-Authored-By: Claude Code --- .env.example | 13 + CLAUDE.md | 13 +- README.md | 49 +- .../[versionId]/download-link/route.ts | 48 ++ app/api/documents/[docId]/versions/route.ts | 162 ++++ db/schema.ts | 94 +++ docker-compose.yml | 58 ++ docs/PLAN-DOCVER.md | 181 +++++ drizzle/0005_icy_bloodaxe.sql | 29 + drizzle/meta/0005_snapshot.json | 735 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/document-versioning.ts | 273 +++++++ lib/docver-store.ts | 275 +++++++ lib/openapi.ts | 243 +++++- middleware.ts | 6 +- package.json | 1 + pnpm-lock.yaml | 459 +++++++---- pnpm-workspace.yaml | 6 + tests/lib/document-versioning.test.ts | 244 ++++++ 19 files changed, 2711 insertions(+), 185 deletions(-) create mode 100644 app/api/documents/[docId]/versions/[versionId]/download-link/route.ts create mode 100644 app/api/documents/[docId]/versions/route.ts create mode 100644 docker-compose.yml create mode 100644 docs/PLAN-DOCVER.md create mode 100644 drizzle/0005_icy_bloodaxe.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 lib/document-versioning.ts create mode 100644 lib/docver-store.ts create mode 100644 pnpm-workspace.yaml create mode 100644 tests/lib/document-versioning.test.ts diff --git a/.env.example b/.env.example index 7e1100d..0a4a54b 100644 --- a/.env.example +++ b/.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 diff --git a/CLAUDE.md b/CLAUDE.md index 8023344..da41165 100644 --- a/CLAUDE.md +++ b/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,13 @@ 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=` / 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//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 (พอร์ตดิบ) -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) +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`) 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 +78,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:" ไม่เจอ 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) diff --git a/README.md b/README.md index 31ffd89..c914253 100644 --- a/README.md +++ b/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":"","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//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 diff --git a/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts b/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts new file mode 100644 index 0000000..0b91a6c --- /dev/null +++ b/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts @@ -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); + } +} diff --git a/app/api/documents/[docId]/versions/route.ts b/app/api/documents/[docId]/versions/route.ts new file mode 100644 index 0000000..d022100 --- /dev/null +++ b/app/api/documents/[docId]/versions/route.ts @@ -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); + } +} diff --git a/db/schema.ts b/db/schema.ts index 0e3c789..840c79d 100644 --- a/db/schema.ts +++ b/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//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>(), + 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 | 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, + }; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6a099c --- /dev/null +++ b/docker-compose.yml @@ -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 _ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม + judes-note-db-data: + name: judes-note-db-data + judes-note-minio-data: + name: judes-note-minio-data diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md new file mode 100644 index 0000000..771669a --- /dev/null +++ b/docs/PLAN-DOCVER.md @@ -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 | 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//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 ทำ) diff --git a/drizzle/0005_icy_bloodaxe.sql b/drizzle/0005_icy_bloodaxe.sql new file mode 100644 index 0000000..09b130c --- /dev/null +++ b/drizzle/0005_icy_bloodaxe.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..e664561 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e3fa33a..7dd298b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts new file mode 100644 index 0000000..9973286 --- /dev/null +++ b/lib/document-versioning.ts @@ -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 | 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; + 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) }; +} + +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 | 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 | 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}"`; +} diff --git a/lib/docver-store.ts b/lib/docver-store.ts new file mode 100644 index 0000000..6191716 --- /dev/null +++ b/lib/docver-store.ts @@ -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//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 }; +}; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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`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!; + }); +} diff --git a/lib/openapi.ts b/lib/openapi.ts index 558a2ce..4cd07d3 100644 --- a/lib/openapi.ts +++ b/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; diff --git a/middleware.ts b/middleware.ts index 4d5d27d..f05c44b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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).+)", ], }; diff --git a/package.json b/package.json index e5d19b2..701ecae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f881ad..772836e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: lucide-react: specifier: ^1.42.0 version: 1.57.0(@types/react@18.3.31)(react@18.3.1) + minio: + specifier: ^8.0.7 + version: 8.0.7 next: specifier: ^14.2.35 version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -80,10 +83,10 @@ importers: version: 29.1.1 office-addin-debugging: specifier: ^7.0.1 - version: 7.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7))(@types/node@26.6.5) + version: 7.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7))(@types/node@26.6.5) office-addin-dev-settings: specifier: ^4.0.1 - version: 4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7))(@types/node@26.6.5) + version: 4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7))(@types/node@26.6.5) postcss: specifier: ^8.5.28 version: 8.5.30 @@ -1166,36 +1169,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.13': resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.13': resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.13': resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.13': resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.13': resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.13': resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} @@ -1430,6 +1439,9 @@ packages: async-mutex@0.3.2: resolution: {integrity: sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1472,6 +1484,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + body-parser@1.20.8: resolution: {integrity: sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1492,11 +1507,18 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + browserslist@4.29.3: resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -1734,6 +1756,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2031,6 +2057,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2109,6 +2138,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -2322,6 +2355,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -2498,24 +2535,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -2674,6 +2715,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minio@8.0.7: + resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} + engines: {node: ^16 || ^18 || >=20} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3065,6 +3110,10 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3290,6 +3339,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + stack-chain@1.3.7: resolution: {integrity: sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==} @@ -3300,6 +3353,12 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + streamroller@3.1.5: resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} engines: {node: '>=8.0'} @@ -3308,6 +3367,10 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3405,6 +3468,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + tinybench@6.2.2: resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} engines: {node: '>=20.0.0'} @@ -3850,85 +3916,85 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/arm-appservice@13.0.3': + '@azure/arm-appservice@13.0.3(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 1.1.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 - '@azure/core-lro': 2.7.2 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) '@azure/core-paging': 1.7.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/arm-resources@5.0.1': + '@azure/arm-resources@5.0.1(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 1.1.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 - '@azure/core-lro': 2.7.2 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) '@azure/core-paging': 1.7.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/arm-storage@17.2.1': + '@azure/arm-storage@17.2.1(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 1.1.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 - '@azure/core-lro': 2.7.2 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) '@azure/core-paging': 1.7.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/arm-subscriptions@5.1.1': + '@azure/arm-subscriptions@5.1.1(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 - '@azure/core-lro': 2.7.2 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) '@azure/core-paging': 1.7.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-auth@1.11.0': + '@azure/core-auth@1.11.0(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-util': 1.14.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-client@1.11.1': + '@azure/core-client@1.11.1(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.1)(@azure/core-rest-pipeline@1.25.0)': + '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.1(supports-color@7.2.0))(@azure/core-rest-pipeline@1.25.0(supports-color@7.2.0))': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-client': 1.11.1 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) - '@azure/core-lro@2.7.2': + '@azure/core-lro@2.7.2(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3939,14 +4005,14 @@ snapshots: '@azure/core-process@1.0.0': {} - '@azure/core-rest-pipeline@1.25.0': + '@azure/core-rest-pipeline@1.25.0(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 - '@typespec/ts-http-runtime': 0.3.9 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3955,10 +4021,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-util@1.14.0': + '@azure/core-util@1.14.0(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@typespec/ts-http-runtime': 0.3.9 + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3968,16 +4034,16 @@ snapshots: fast-xml-parser: 5.11.2 tslib: 2.8.1 - '@azure/identity@4.14.0': + '@azure/identity@4.14.0(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) '@azure/core-process': 1.0.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) '@azure/msal-browser': 5.25.1 '@azure/msal-node': 6.0.1 open: 11.0.4 @@ -3985,9 +4051,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/logger@1.4.0': + '@azure/logger@1.4.0(supports-color@7.2.0)': dependencies: - '@typespec/ts-http-runtime': 0.3.9 + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -4015,35 +4081,35 @@ snapshots: '@azure/msal-common': 16.14.1 jsonwebtoken: 9.0.3 - '@azure/storage-blob@12.34.0': + '@azure/storage-blob@12.34.0(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.1 - '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.1)(@azure/core-rest-pipeline@1.25.0) - '@azure/core-lro': 2.7.2 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.1(supports-color@7.2.0))(@azure/core-rest-pipeline@1.25.0(supports-color@7.2.0)) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) '@azure/core-paging': 1.7.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) '@azure/core-xml': 1.6.0 - '@azure/logger': 1.4.0 - '@azure/storage-common': 12.5.0(@azure/core-client@1.11.1) + '@azure/logger': 1.4.0(supports-color@7.2.0) + '@azure/storage-common': 12.5.0(@azure/core-client@1.11.1(supports-color@7.2.0))(supports-color@7.2.0) apache-arrow: 21.2.0 events: 3.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/storage-common@12.5.0(@azure/core-client@1.11.1)': + '@azure/storage-common@12.5.0(@azure/core-client@1.11.1(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.1)(@azure/core-rest-pipeline@1.25.0) - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.1(supports-color@7.2.0))(@azure/core-rest-pipeline@1.25.0(supports-color@7.2.0)) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) events: 3.3.0 tslib: 2.8.1 transitivePeerDependencies: @@ -4508,20 +4574,20 @@ snapshots: node-fetch: 3.3.2 strip-bom: 5.0.0 - '@microsoft/dev-tunnels-contracts@1.1.9': + '@microsoft/dev-tunnels-contracts@1.1.9(supports-color@7.2.0)': dependencies: buffer: 5.7.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) vscode-jsonrpc: 4.0.0 transitivePeerDependencies: - supports-color - '@microsoft/dev-tunnels-management@1.1.9': + '@microsoft/dev-tunnels-management@1.1.9(supports-color@7.2.0)': dependencies: - '@microsoft/dev-tunnels-contracts': 1.1.9 - axios: 1.20.0(debug@4.4.3) + '@microsoft/dev-tunnels-contracts': 1.1.9(supports-color@7.2.0) + axios: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) buffer: 5.7.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) vscode-jsonrpc: 4.0.0 transitivePeerDependencies: - supports-color @@ -4544,33 +4610,33 @@ snapshots: transitivePeerDependencies: - encoding - '@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7)': + '@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7)': dependencies: - '@azure/arm-subscriptions': 5.1.1 - '@azure/core-auth': 1.11.0 - '@azure/identity': 4.14.0 + '@azure/arm-subscriptions': 5.1.1(supports-color@7.2.0) + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/identity': 4.14.0(supports-color@7.2.0) '@azure/msal-node': 6.0.1 '@azure/msal-node-extensions': 1.5.32 '@inquirer/core': 5.1.2 '@inquirer/prompts': 7.10.1(@types/node@26.6.5) '@inquirer/type': 1.5.5 - '@microsoft/teamsfx-api': 0.24.3 - '@microsoft/teamsfx-core': 3.1.4(@types/node@26.6.5)(zod@4.6.7) + '@microsoft/teamsfx-api': 0.24.3(supports-color@7.2.0) + '@microsoft/teamsfx-core': 3.1.4(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7) ansi-escapes: 4.3.2 applicationinsights: 1.8.10 async-mutex: 0.3.2 chalk: 4.1.2 cli-table3: 0.6.5 dotenv: 8.6.0 - express: 4.22.3 + express: 4.22.3(supports-color@7.2.0) figures: 3.2.0 fs-extra: 9.1.0 lodash: 4.18.1 - log4js: 6.9.1 + log4js: 6.9.1(supports-color@7.2.0) node-machine-id: 1.1.12 open: 8.4.2 semver: 7.8.5 - tas-client: 0.1.73 + tas-client: 0.1.73(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) tree-kill: 1.2.2 underscore: 1.13.8 optionalDependencies: @@ -4584,9 +4650,9 @@ snapshots: - supports-color - zod - '@microsoft/teamsfx-api@0.24.3': + '@microsoft/teamsfx-api@0.24.3(supports-color@7.2.0)': dependencies: - '@azure/core-auth': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) '@microsoft/app-manifest': 1.1.3 chai: 4.5.0 jsonschema: 1.5.0 @@ -4595,38 +4661,38 @@ snapshots: transitivePeerDependencies: - supports-color - '@microsoft/teamsfx-core@3.1.4(@types/node@26.6.5)(zod@4.6.7)': + '@microsoft/teamsfx-core@3.1.4(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7)': dependencies: '@apidevtools/swagger-parser': 10.1.1(openapi-types@7.2.3) - '@azure/arm-appservice': 13.0.3 - '@azure/arm-resources': 5.0.1 - '@azure/arm-storage': 17.2.1 - '@azure/arm-subscriptions': 5.1.1 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 - '@azure/identity': 4.14.0 - '@azure/storage-blob': 12.34.0 + '@azure/arm-appservice': 13.0.3(supports-color@7.2.0) + '@azure/arm-resources': 5.0.1(supports-color@7.2.0) + '@azure/arm-storage': 17.2.1(supports-color@7.2.0) + '@azure/arm-subscriptions': 5.1.1(supports-color@7.2.0) + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/identity': 4.14.0(supports-color@7.2.0) + '@azure/storage-blob': 12.34.0(supports-color@7.2.0) '@feathersjs/hooks': 0.6.5 - '@microsoft/dev-tunnels-contracts': 1.1.9 - '@microsoft/dev-tunnels-management': 1.1.9 + '@microsoft/dev-tunnels-contracts': 1.1.9(supports-color@7.2.0) + '@microsoft/dev-tunnels-management': 1.1.9(supports-color@7.2.0) '@microsoft/kiota': 1.31.1 '@microsoft/m365-spec-parser': 0.3.3 - '@microsoft/teamsfx-api': 0.24.3 - '@modelcontextprotocol/sdk': 1.32.1(zod@4.6.7) + '@microsoft/teamsfx-api': 0.24.3(supports-color@7.2.0) + '@modelcontextprotocol/sdk': 1.32.1(supports-color@7.2.0)(zod@4.6.7) adm-zip: 0.6.1 ajv: 8.20.0 - axios: 1.20.0(debug@4.4.3) + axios: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) axios-retry: 3.9.1 comment-json: 4.6.2 cryptr: 6.4.0 deep-diff: 1.0.2 - detect-port: 1.6.1 + detect-port: 1.6.1(supports-color@7.2.0) dotenv: 8.6.0 form-data: 4.0.6 fs-extra: 9.1.0 glob: 13.0.6 handlebars: 4.7.10 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) iconv-lite: 0.6.3 ignore: 5.3.2 js-base64: 3.9.4 @@ -4667,7 +4733,7 @@ snapshots: - supports-color - zod - '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': + '@modelcontextprotocol/sdk@1.32.1(supports-color@7.2.0)(zod@4.6.7)': dependencies: '@hono/node-server': 2.1.5(hono@4.13.13) ajv: 8.20.0 @@ -4677,8 +4743,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 - express: 5.3.0 - express-rate-limit: 8.7.1(express@5.3.0) + express: 5.3.0(supports-color@7.2.0) + express-rate-limit: 8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0) hono: 4.13.13 jose: 6.2.12 json-schema-typed: 8.0.2 @@ -4909,10 +4975,10 @@ snapshots: '@types/wrap-ansi@3.0.0': {} - '@typespec/ts-http-runtime@0.3.9': + '@typespec/ts-http-runtime@0.3.9(supports-color@7.2.0)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -4954,9 +5020,9 @@ snapshots: adm-zip@0.6.1: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5039,6 +5105,8 @@ snapshots: dependencies: tslib: 2.8.1 + async@3.2.6: {} + asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -5057,11 +5125,11 @@ snapshots: '@babel/runtime': 7.29.10 is-retry-allowed: 2.2.0 - axios@1.20.0(debug@4.4.3): + axios@1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - follow-redirects: 1.16.1(debug@4.4.3) + follow-redirects: 1.16.1(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -5085,11 +5153,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.8: + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + + body-parser@1.20.8(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 2.0.0 destroy: 1.2.0 http-errors: 2.0.1 @@ -5102,11 +5174,11 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0: + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -5126,6 +5198,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-or-node@2.1.1: {} + browserslist@4.29.3: dependencies: baseline-browser-mapping: 2.11.28 @@ -5134,6 +5208,8 @@ snapshots: node-releases: 2.0.58 update-browserslist-db: 1.3.4(browserslist@4.29.3) + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -5339,16 +5415,22 @@ snapshots: date-format@4.0.14: {} - debug@2.6.9: + debug@2.6.9(supports-color@7.2.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 7.2.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} + decode-uri-component@0.2.2: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -5380,10 +5462,10 @@ snapshots: detect-libc@2.1.2: {} - detect-port@1.6.1: + detect-port@1.6.1(supports-color@7.2.0): dependencies: address: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5596,6 +5678,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} eventsource-parser@3.1.1: {} @@ -5608,29 +5692,29 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.7.1(express@5.3.0): + express-rate-limit@8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 4.4.3 - express: 5.3.0 + debug: 4.4.3(supports-color@7.2.0) + express: 5.3.0(supports-color@7.2.0) ip-address: 10.7.4 transitivePeerDependencies: - supports-color - express@4.22.3: + express@4.22.3(supports-color@7.2.0): dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.8 + body-parser: 1.20.8(supports-color@7.2.0) content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.0.7 - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.2 + finalhandler: 1.3.2(supports-color@7.2.0) fresh: 0.5.2 http-errors: 2.0.1 merge-descriptors: 1.0.3 @@ -5642,8 +5726,8 @@ snapshots: qs: 6.16.0 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 + send: 0.19.2(supports-color@7.2.0) + serve-static: 1.16.3(supports-color@7.2.0) setprototypeof: 1.2.0 statuses: 2.0.2 type-is: 1.6.18 @@ -5652,20 +5736,20 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.3.0: + express@5.3.0(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@7.2.0) content-disposition: 2.0.1 content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -5676,9 +5760,9 @@ snapshots: proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -5734,9 +5818,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: + filter-obj@1.1.0: {} + + finalhandler@1.3.2(supports-color@7.2.0): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -5746,9 +5832,9 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -5761,9 +5847,9 @@ snapshots: flatted@3.4.4: {} - follow-redirects@1.16.1(debug@4.4.3): + follow-redirects@1.16.1(debug@4.4.3(supports-color@7.2.0)): optionalDependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) form-data@4.0.6: dependencies: @@ -5911,26 +5997,26 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color http2-client@1.3.5: {} - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@7.2.0): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5970,6 +6056,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.5.0: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -6190,13 +6278,13 @@ snapshots: lodash@4.18.1: {} - log4js@6.9.1: + log4js@6.9.1(supports-color@7.2.0): dependencies: date-format: 4.0.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) flatted: 3.4.4 rfdc: 1.4.1 - streamroller: 3.1.5 + streamroller: 3.1.5(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6271,6 +6359,22 @@ snapshots: minimist@1.2.8: {} + minio@8.0.7: + dependencies: + async: 3.2.6 + block-stream2: 2.1.0 + browser-or-node: 2.1.1 + buffer-crc32: 1.0.0 + eventemitter3: 5.0.4 + fast-xml-parser: 5.11.2 + ipaddr.js: 2.5.0 + lodash: 4.18.1 + mime-types: 2.1.35 + query-string: 7.1.3 + stream-json: 1.9.1 + through2: 4.0.2 + xml2js: 0.5.0 + minipass@7.1.3: {} minizlib@3.1.0: @@ -6426,14 +6530,14 @@ snapshots: transitivePeerDependencies: - encoding - office-addin-debugging@7.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7))(@types/node@26.6.5): + office-addin-debugging@7.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7))(@types/node@26.6.5): dependencies: adm-zip: 0.6.1 commander: 13.1.0 node-fetch: 2.7.0 office-addin-cli: 3.0.1 office-addin-dev-certs: 3.0.1 - office-addin-dev-settings: 4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7))(@types/node@26.6.5) + office-addin-dev-settings: 4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7))(@types/node@26.6.5) office-addin-manifest: 3.0.1 office-addin-node-debugger: 2.0.1 office-addin-usage-data: 3.0.1 @@ -6454,9 +6558,9 @@ snapshots: transitivePeerDependencies: - encoding - office-addin-dev-settings@4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(zod@4.6.7))(@types/node@26.6.5): + office-addin-dev-settings@4.0.1(@microsoft/m365agentstoolkit-cli@1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7))(@types/node@26.6.5): dependencies: - '@microsoft/m365agentstoolkit-cli': 1.1.18(@types/node@26.6.5)(zod@4.6.7) + '@microsoft/m365agentstoolkit-cli': 1.1.18(@types/node@26.6.5)(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)(zod@4.6.7) adm-zip: 0.6.1 commander: 13.1.0 fs-extra: 11.4.1 @@ -6711,6 +6815,13 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + queue-microtask@1.2.3: {} range-parser@1.2.1: {} @@ -6809,9 +6920,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -6851,9 +6962,9 @@ snapshots: semver@7.8.5: {} - send@0.19.2: + send@0.19.2(supports-color@7.2.0): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@7.2.0) depd: 2.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -6869,9 +6980,9 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -6885,21 +6996,21 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3: + serve-static@1.16.3(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.2 + send: 0.19.2(supports-color@7.2.0) transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6990,22 +7101,32 @@ snapshots: source-map@0.6.1: {} + split-on-first@1.1.0: {} + stack-chain@1.3.7: {} statuses@2.0.2: {} std-env@4.3.0: {} - streamroller@3.1.5: + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + streamroller@3.1.5(supports-color@7.2.0): dependencies: date-format: 4.0.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) fs-extra: 8.1.0 transitivePeerDependencies: - supports-color streamsearch@1.1.0: {} + strict-uri-encode@2.0.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -7128,9 +7249,9 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tas-client@0.1.73: + tas-client@0.1.73(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - axios: 1.20.0(debug@4.4.3) + axios: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - debug - supports-color @@ -7150,6 +7271,10 @@ snapshots: dependencies: any-promise: 1.3.0 + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + tinybench@6.2.2: {} tinyexec@1.3.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..69f1148 --- /dev/null +++ b/pnpm-workspace.yaml @@ -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 diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts new file mode 100644 index 0000000..635fb84 --- /dev/null +++ b/tests/lib/document-versioning.test.ts @@ -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(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"), + ); + }); +});