diff --git a/CLAUDE.md b/CLAUDE.md index 38926f8..0bb872c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,14 +52,14 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow 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 + 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): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) +lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) 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 (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) 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 ใช้ร่วม) @@ -83,7 +83,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/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/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/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า 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/app/api/documents/versions/route.ts b/app/api/documents/versions/route.ts index 79dcc2b..2a5248d 100644 --- a/app/api/documents/versions/route.ts +++ b/app/api/documents/versions/route.ts @@ -6,7 +6,8 @@ // ไม่รับจาก URL/form อีก (lib/docver-token.ts) import { createHash, randomUUID } from "node:crypto"; -import { toDocumentVersionDTO } from "@/db/schema"; +import { toDocumentVersionDTO, type DocumentVersionFields } from "@/db/schema"; +import { addinTicketSecret } from "@/lib/addin-ticket"; import { docverError, DOCVER_MAX_UPLOAD_BYTES, @@ -16,7 +17,9 @@ import { parseVersionUploadForm, sanitizeFilePartName, } from "@/lib/document-versioning"; +import { extractContentControls } from "@/lib/lib-tmpl"; import { requireDocverBearer } from "@/lib/docver-token"; +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; import { findDocumentByDocId, insertVersionRecord, @@ -31,6 +34,7 @@ export const dynamic = "force-dynamic"; /** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) * ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → + * แกะ fields จากไฟล์ (lib-tmpl เหมือน /api/template/extract) → * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ @@ -92,6 +96,21 @@ export async function POST(request: Request) { return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400); } + // fields snapshot แกะจากไฟล์เองฝั่ง server — **เก็บทั้งก้อนเหมือน return ของ + // /api/template/extract** ({cc, bookmark, properties, verify} ผ่าน extractDocumentSnapshot + // ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น — 2026-10-30 ถอด form field "fields" + // ที่ add-in อ่านผ่าน Office.js ออกจาก contract): มองเห็น CC ทุกชนิดเสมอไม่ขึ้นกับ + // เวอร์ชัน WordApi ของ host · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket + // ฝั่ง server (env chain เดียวกับ lib/addin-ticket.ts) · ZIP ไม่มี word/document.xml, + // parse ไม่ได้ หรือ ticket_data ไม่ใช่ JSON = 400 + let fields: DocumentVersionFields; + try { + fields = extractDocumentSnapshot(docxBytes, { secret: addinTicketSecret() }); + } catch (err) { + console.error("docver: แกะข้อมูลจากไฟล์ไม่สำเร็จ", err); + return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (แกะข้อมูลไม่สำเร็จ)", 400); + } + const contentHash = createHash("sha256").update(docxBytes).digest("hex"); const versionId = randomUUID(); // ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) → @@ -119,7 +138,7 @@ export async function POST(request: Request) { clientVersionId: parsed.form.clientVersionId, contentHash, message: parsed.form.message, - fields: parsed.form.fields, + fields, templateId: auth.claims.template, fileName: docxDisplayName, docxObjectKey: keys.docx, diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index 12108b7..bc96235 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; // export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = @@ -40,32 +39,13 @@ export async function POST(request: Request) { // File → Buffer const buffer = Buffer.from(await file.arrayBuffer()); - // ใช้ lib-tmpl.ts เดิม - // const data = extractContentControls(buffer); - - const cc = extractContentControls(buffer); - const bookmark = extractBookmark(buffer) - const properties = getDocProps(buffer) - let verify: Record ={} - const ticket_data = properties?.custom?.ticket_data - const HmacSha256 = String(properties?.custom?.HmacSha256) - const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data && typeof ticket_data === 'string'){ - const ticketDataObj= JSON.parse(ticket_data) - const unix_timestamp = Number(ticketDataObj.exp)*1000 - if(unix_timestamp){ - verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }) - } - if(TEST_SECRET && HmacSha256){ - verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) - } - if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) - } - } - - let returnData : Record = {cc,bookmark,properties,verify} + // ชุดแกะข้อมูล `{cc, bookmark, properties, verify}` จาก lib-tmpl.ts — ตัวเดียวกับที่ + // ใช้เก็บเป็น fields ของ version ตอน POST /api/documents/versions (2026-10-30) · + // secret/public_key เป็นของผู้เรียกเพื่อ "ทดสอบ verify" เฉพาะฝั่งนี้ + const returnData = extractDocumentSnapshot(buffer, { + secret: TEST_SECRET || undefined, + publicKeyHex: TEST_PUBLIC_KEY_HEX || undefined, + }); return NextResponse.json(returnData); } catch (error) { console.error(error); diff --git a/db/schema.ts b/db/schema.ts index 840c79d..3ded779 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -12,6 +12,9 @@ import { uuid, } from "drizzle-orm/pg-core"; +// type-only — ไม่ลาก runtime ของ lib-tmpl มาที่ schema (ใช้เป็นชนิดของ fields jsonb) +import type { DocumentExtractSnapshot } from "@/lib/lib-tmpl"; + // ─── Enum ──────────────────────────────────────────────────────────────────── export const annotationType = pgEnum("annotation_type", [ @@ -275,6 +278,14 @@ export const documents = pgTable( /** 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 ผู้ใช้ */ + +/** fields ของ version — **ก้อนเดียวกับ return ของ `/api/template/extract`** (ผ่าน + * `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกัน): `{cc, bookmark, properties, + * verify}` แกะจากไฟล์ .docx ฝั่ง server ตอน upload (2026-10-30) · row เก่าก่อนวันนั้น + * เก็บ map ดิบ `{ [tag]: text }` / `{ [fieldKey]: {value, missing} }` — ฝั่งอ่านของ add-in + * (FieldsSnapshot + readSnapshotField) แยกรูปเอง จึงไม่ต้อง migrate ข้อมูลเก่า */ +export type DocumentVersionFields = DocumentExtractSnapshot; + export const documentVersions = pgTable( "document_versions", { @@ -290,9 +301,9 @@ export const documentVersions = pgTable( 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>(), + /** snapshot ตอน upload: ก้อน extract snapshot `{cc, bookmark, properties, verify}` + * เหมือน return ของ /api/template/extract (ดู DocumentVersionFields เหนือตาราง) */ + fields: jsonb("fields").$type(), templateId: text("template_id"), /** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) — * ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */ @@ -447,7 +458,7 @@ export type DocumentVersionDTO = { createdByUserId: string; createdByUserName: string; contentHash: string; - fields: Record | null; + fields: DocumentVersionFields | null; message: string | null; hasPdf: boolean; }; diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 23271c9..e3abd79 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -80,13 +80,23 @@ multipart/form-data: | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | -| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls ทุกชนิด (add-in ใช้ `Document.getContentControls()` บน WordApi 1.5+; host ต่ำกว่าเห็นเฉพาะ rich text — ดู `docs/addin-ui-flow.md` §9 ข้อ 7) (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | - field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) — **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน + · **breaking change 2026-10-30:** `fields` ถอดออกจาก form เช่นกัน (ส่งมา = 400) — + `fields` ของ version ถูกแกะจากไฟล์ .docx ที่อัปโหลด**ฝั่ง server** เก็บ**ทั้งก้อน + เหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` + ผ่าน `extractDocumentSnapshot` ของ `lib/lib-tmpl.ts` ตัวเดียวกับ route นั้น — route + extract เรียก helper ตัวเดียวกัน จึงตรงกันเป๊ะโดยการก่อสร้าง) · `verify.HmacSha256` + ตรวจด้วย secret ของ add-in ticket ฝั่ง server (env chain `ADDIN_TICKET_SECRET` → + `SECRET` → `"super_secret"` ของ `lib/addin-ticket.ts` — ต่างจาก extract ที่ใช้ secret + ของผู้เรียกเพื่อทดสอบ) · เห็น content control ทุกชนิดเสมอ ไม่ขึ้นกับเวอร์ชัน WordApi + ของ host แบบตอน add-in อ่านเองผ่าน Office.js · ไฟล์ที่แกะไม่ได้ (ไม่มี + `word/document.xml`/ZIP เสีย/`ticket_data` ไม่ใช่ JSON) = 400 · snapshot เก็บทุกครั้ง + ที่แกะสำเร็จ (NULL เหลือเฉพาะ row เก่าก่อน 2026-10-30 ที่เก็บ map ดิบ) - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` @@ -106,7 +116,16 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า createdByUserId: string; // = user_id createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม) contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server) - fields: Record | null; + fields: { // ก้อนเดียวกับ return ของ /api/template/extract (2026-10-30) + cc: Record; // ค่า Content Control { [tag]: text } + bookmark: Record; // ข้อความใน bookmark { [name]: text } + properties: { // core/extended/custom props (Date → ISO string) + standard?: Record; + extended?: Record; + custom?: Record; + }; + verify: Record; // exp (ฟอร์แมตไทย) + HmacSha256 (secret ฝั่ง server) + } | null; // null = row เก่าก่อน 2026-10-30 (map ดิบ) หรือไม่มีเลย message: string | null; hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว } @@ -131,7 +150,7 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า | เหตุ | สถานะ | |---|---| -| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | +| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ multipart · magic bytes ผิด · แกะข้อมูลจากไฟล์ไม่ได้ · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | | `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | | เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | @@ -174,7 +193,9 @@ document_versions — id uuid PK · document_id uuid FK→documents CASCADE ## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า -validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId` +validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → **แกะ fields snapshot +จากไฟล์ทั้งก้อนด้วย `extractDocumentSnapshot` ของ lib/lib-tmpl.ts (ตัวเดียวกับ +`/api/template/extract` — 2026-10-30)** → sha256 → `versionId` → **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** — ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4) diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index 7d1dd63..69ee6fd 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -25,12 +25,13 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ /** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ * (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token - * ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */ + * ผ่าน lib/docver-token.ts · `fields` ถอดออก 2026-10-30 — server แกะจากไฟล์ .docx + * ด้วย extractContentControls ของ lib/lib-tmpl.ts ตัวเดียวกับ /api/template/extract + * ส่ง field เก่ามา = 400 ตาม convention) */ const FORM_FIELDS = [ "file", "pdf", "clientVersionId", - "fields", "message", "fileName", ] as const; @@ -108,39 +109,10 @@ 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); - // เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้: - // เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb - // ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ) - if (keys.length > 1_000) { - return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' }; - } - 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' }; - } - } - return { ok: true, fields: keys.length === 0 ? null : (parsed as Record) }; -} +/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — ถอดออกแล้ว + * (2026-10-30): form field "fields" ยกเลิก ไม่รับจากผู้เรียกอีก — fields ของ version + * แกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server ด้วย extractContentControls ของ + * lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract) ที่ route ของ POST versions */ export type ParsedVersionUploadForm = { clientVersionId: string | null; @@ -148,7 +120,6 @@ export type ParsedVersionUploadForm = { /** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง * เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */ fileName: string | null; - fields: Record | null; }; /** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน @@ -180,15 +151,6 @@ export function parseVersionUploadForm(form: FormData): 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; @@ -198,7 +160,6 @@ export function parseVersionUploadForm(form: FormData): clientVersionId: clientVersionId.value, message: message.value, fileName: sanitizeDisplayFileName(rawFileName.value), - fields, }, }; } diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 08a6575..4a98f3a 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -13,6 +13,7 @@ import { documents, documentVersions, type DocumentRow, + type DocumentVersionFields, type DocumentVersionRow, } from "@/db/schema"; import { @@ -229,7 +230,8 @@ export type InsertVersionInput = { clientVersionId: string | null; contentHash: string; message: string | null; - fields: Record | null; + /** ก้อนเดียวกับ return ของ /api/template/extract (ดู DocumentVersionFields ใน db/schema.ts) */ + fields: DocumentVersionFields | null; templateId: string | null; fileName: string | null; docxObjectKey: string; diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index bb293d0..631ff46 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1,4 +1,4 @@ -import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils'; +import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils'; import createReport from 'docx-templates'; import PizzaZip from "pizzip"; import { @@ -1217,6 +1217,71 @@ export function getDocProps( } +//**************************** document snapshot ******************************************* + +/** DocPropsResult หลังทำ JSON-safe (Date → ISO string) — ชนิดของ `properties` ใน + * DocumentExtractSnapshot ทั้งฝั่ง response ของ /api/template/extract และ column + * `fields` jsonb ของ document_versions (ค่าข้าม server→client เป็น string เสมอ) */ +export interface DocPropsJson { + standard?: Record; + extended?: Record; + custom?: Record; +} + +/** รูป return ของ /api/template/extract — ใช้ร่วม 2 ที่: route นั้นคืนทั้งก้อนตรง ๆ + * และ POST /api/documents/versions เก็บทั้งก้อนเป็น fields ของ version (2026-10-30) */ +export interface DocumentExtractSnapshot { + /** ค่า Content Control `{ [tag]: text }` */ + cc: ExtractedData; + /** ข้อความใน bookmark `{ [name]: text }` */ + bookmark: ExtractedData; + /** document properties (standard/extended/custom) */ + properties: DocPropsJson; + /** ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (ฟอร์แมตไทย) + HmacSha256/Ed25519 เฉพาะเมื่อ + * ผู้เรียกส่ง secret/publicKeyHex มาให้ใช้ตรวจ */ + verify: Record; +} + +/** แกะข้อมูลจาก docx ทั้งชุด `{cc, bookmark, properties, verify}` — logic ย้ายมาจาก + * app/api/template/extract/route.ts แบบเดิม (แถวต่อแถว) เพื่อให้ return ของ extract + * กับ fields ของ version ที่สร้างใหม่ตรงกันเป๊ะโดยการก่อสร้าง + * · secret/publicKeyHex = ของผู้เรียกเพื่อ "ทดสอบ verify" (extract) หรือ secret ของ + * add-in ticket ฝั่ง server (ตอนสร้าง version) — ไม่ส่ง = ไม่มี key นั้นใน verify + * · ticket_data ที่ไม่ใช่ JSON จะ throw ผู้เรียกจัดการเอง (extract = 500 / versions = 400) */ +export function extractDocumentSnapshot( + docxBuffer: Buffer, + options: { secret?: string; publicKeyHex?: string } = {}, +): DocumentExtractSnapshot { + const cc = extractContentControls(docxBuffer); + const bookmark = extractBookmark(docxBuffer); + // JSON-safe ทันที (Date → ISO string): response ของ extract กับ jsonb ของ version ได้ + // ค่าชนิดเดียวกัน (NextResponse.json / drizzle serialize Date เป็น ISO อยู่แล้ว — + // ผลลัพธ์ JSON ไม่ต่างจากเดิม เปลี่ยนแค่ชนิดฝั่ง TS) + const properties = JSON.parse(JSON.stringify(getDocProps(docxBuffer))) as DocPropsJson; + + const verify: Record = {}; + const ticket_data = properties.custom?.ticket_data; + const HmacSha256 = String(properties.custom?.HmacSha256); + const Ed25519 = String(properties.custom?.Ed25519); + + if (ticket_data && typeof ticket_data === "string") { + const ticketDataObj = JSON.parse(ticket_data); + const unix_timestamp = Number(ticketDataObj.exp) * 1000; + if (unix_timestamp) { + verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }); + } + if (options.secret && HmacSha256) { + verify['HmacSha256'] = verifyHmacSha256(ticket_data, options.secret, HmacSha256); + } + if (options.publicKeyHex && Ed25519) { + verify["Ed25519"] = verifyEd25519(ticket_data, Ed25519, options.publicKeyHex); + } + } + + return { cc, bookmark, properties, verify }; +} + + //**************************** dynamic content ******************************************* // sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง) type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; diff --git a/lib/openapi.ts b/lib/openapi.ts index 7700d71..43eff5d 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -705,7 +705,11 @@ export const openApiSpec = { "- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " + "หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" + "- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " + - "**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**", + "**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**\n" + + "- `fields` ของ version ถูก server แกะจากไฟล์ .docx ที่อัปโหลดเอง เก็บ**ทั้งก้อนเหมือน " + + "return ของ `/api/template/extract`** (`{cc, bookmark, properties, verify}` — ผ่าน helper " + + "ตัวเดียวกัน) โดย `verify.HmacSha256` ตรวจด้วย secret ของ ticket ฝั่ง server — " + + "**ห้ามส่ง field `fields` ใน form (ส่งมา = 400)**", security: [{ DocverBearer: [] }], requestBody: { required: true, @@ -730,13 +734,6 @@ export const openApiSpec = { type: "string", description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)", }, - fields: { - type: "string", - description: - 'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' + - '`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' + - 'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}', - }, message: { type: "string", description: "commit message ของ version (≤ 2048 ตัวอักษร)", @@ -1166,6 +1163,40 @@ export const openApiSpec = { }, }, }, + ExtractSnapshot: { + type: "object", + description: + "ก้อนข้อมูลแกะจากไฟล์ .docx — รูปเดียวกับ return ของ POST /api/template/extract " + + "(ใช้ helper ตัวเดียวกันใน lib/lib-tmpl.ts)", + properties: { + cc: { + type: "object", + description: "ค่า Content Control ทุกตัว รูปแบบ `{ [tag]: ข้อความ }`", + additionalProperties: { type: "string" }, + }, + bookmark: { + type: "object", + description: "ข้อความใน bookmark รูปแบบ `{ [ชื่อ bookmark]: ข้อความ }`", + additionalProperties: { type: "string" }, + }, + properties: { + type: "object", + description: "document properties — standard (core) / extended (app) / custom", + properties: { + standard: { type: "object", additionalProperties: { type: "string" } }, + extended: { type: "object", additionalProperties: { type: "string" } }, + custom: { type: "object", additionalProperties: {} }, + }, + }, + verify: { + type: "object", + description: + "ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (วันที่ไทย +07:00) และ HmacSha256 " + + "(boolean — ตรวจด้วย secret ของ ticket ฝั่ง server)", + additionalProperties: {}, + }, + }, + }, VersionRecord: { type: "object", description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)", @@ -1180,11 +1211,11 @@ export const openApiSpec = { description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)", }, fields: { - type: "object", nullable: true, description: - "ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)", - additionalProperties: { type: "string" }, + "snapshot ตอนอัปโหลด — **ก้อนเดียวกับ return ของ /api/template/extract** (server " + + "แกะจากไฟล์ .docx เอง; null = version เก่าก่อน 2026-10-30)", + allOf: [{ $ref: "#/components/schemas/ExtractSnapshot" }], }, message: { type: "string", nullable: true, description: "commit message" }, hasPdf: { type: "boolean" }, diff --git a/scripts/make-ticket-docx.mjs b/scripts/make-ticket-docx.mjs index e22f6d0..9292581 100644 --- a/scripts/make-ticket-docx.mjs +++ b/scripts/make-ticket-docx.mjs @@ -125,10 +125,12 @@ function unescapeXml(value) { .replace(/&/g, "&"); } -/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ +/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว + * (match ทั้งพิมพ์เล็กของตัว stamp จริง/setCustomDocProps และพิมพ์ใหญ่ + * ที่ script นี้เคยเขียนก่อน 2026-10-30 — อ่านไฟล์เก่าที่ stampค้างไว้ได้) */ function readProperties(xml) { const properties = []; - const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|]*name="([^"]*)"[^>]*\/>/g; + const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/property>|]*name="([^"]*)"[^>]*\/>/gi; let match; while ((match = regex.exec(xml)) !== null) { const name = match[1] ?? match[3]; @@ -148,7 +150,10 @@ function usedPids(xml) { } function propertyXml(name, pid, value) { - return ` ${escapeXml(value)}\n`; + // `` พิมพ์เล็กตาม schema OOXML (ct_Property) — ตรงกับตัว stamp จริง + // setCustomDocProps ของ lib/lib-tmpl.ts เพื่อให้ getCustomDocProps/getDocProps + // (ที่ /api/template/extract กับ fields ของ version ใช้อ่าน) มองเห็นด้วย + return ` ${escapeXml(value)}\n`; } /** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */ @@ -156,9 +161,10 @@ function upsertProperties(existingXml, entries) { // เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง) let xml = existingXml; for (const entry of entries) { + // gi — ถอดของเก่าทั้งพิมพ์เล็ก (ตัวจริง) และพิมพ์ใหญ่ (ที่ script นี้เคยเขียน) ก่อนแทรกใหม่ const removal = new RegExp( - `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, - "g", + `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, + "gi", ); xml = xml.replace(removal, ""); } @@ -189,7 +195,7 @@ function ensurePartRegistered(zip) { .asText() .replace( /<\/Types>/, - ` \n`, + ` \n`, ); zip.file(CT_PATH, updated); } diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index a8d13a4..090e7f2 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -7,7 +7,6 @@ import { isDocxBytes, isPdfBytes, parseDownloadFormat, - parseFieldsJson, parseVersionIdParam, parseVersionUploadForm, sanitizeDisplayFileName, @@ -71,23 +70,6 @@ describe("isDocxBytes / isPdfBytes", () => { }); }); -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", () => { // identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก // Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts) @@ -101,7 +83,6 @@ describe("parseVersionUploadForm", () => { const form = baseForm(); form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 "); form.append("message", " "); - form.append("fields", '{"blackno":"1"}'); form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx"); const result = parseVersionUploadForm(form); ok(result); @@ -109,7 +90,6 @@ describe("parseVersionUploadForm", () => { clientVersionId: "11111111-1111-4111-8111-111111111111", message: null, fileName: "คำสั่งเรียก.docx", - fields: { blackno: "1" }, }); }); @@ -131,6 +111,14 @@ describe("parseVersionUploadForm", () => { } }); + it("ส่ง fields ใน form = 400 (2026-10-30 — server แกะจากไฟล์เองแบบ /api/template/extract)", () => { + const form = baseForm(); + form.append("fields", '{"blackno":"1"}'); + const result = parseVersionUploadForm(form); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("fields"); + }); + it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => { const form = baseForm(); form.append("message", new File(["x"], "m.txt")); @@ -147,31 +135,6 @@ describe("parseVersionUploadForm", () => { 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); - }); - - it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => { - const big = baseForm(); - big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) })); - expect(parseVersionUploadForm(big).ok).toBe(true); - - const manyKeys = baseForm(); - manyKeys.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(manyKeys).ok).toBe(true); - const over = baseForm(); - over.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(over).ok).toBe(false); - }); }); describe("sanitizeDisplayFileName / sanitizeFilePartName", () => { diff --git a/tests/lib/lib-tmpl.test.ts b/tests/lib/lib-tmpl.test.ts new file mode 100644 index 0000000..fd585d8 --- /dev/null +++ b/tests/lib/lib-tmpl.test.ts @@ -0,0 +1,101 @@ +// extractDocumentSnapshot — หัวใจของ "fields ของ version = return ของ /api/template/extract" +// (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน +// ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย +// (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*) +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import PizzaZip from "pizzip"; + +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; + +const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; +const VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; + +function escapeXml(unsafe: string): string { + return unsafe.replace(/&/g, "&").replace(//g, ">"); +} + +/** docx จิ๋วในหน่วยความจำ: CC 1 ตัว (tag blackno) + bookmark sec1 ครอบ 2 run */ +function documentXml(): string { + return ( + `\n` + + `` + + `` + + `ด1/2569` + + `` + + `hello world` + + `` + + `` + ); +} + +function customPropsXml(ticketData: string, hmacHex: string): string { + const prop = (pid: number, name: string, value: string) => + `` + + `${escapeXml(value)}`; + return ( + `\n` + + `` + + prop(2, "ticket_data", ticketData) + + prop(3, "HmacSha256", hmacHex) + + `` + ); +} + +function buildDocx(entries: Record): Buffer { + const zip = new PizzaZip(); + for (const [name, content] of Object.entries(entries)) { + zip.file(name, content); + } + return Buffer.from(zip.generate({ type: "nodebuffer" })); +} + +const TICKET_DATA = JSON.stringify({ exp: 2_000_000_000, fid: "F1", uid: "u1", uname: "ทดสอบ" }); +const TICKET_HMAC = createHmac("sha256", "super_secret").update(TICKET_DATA, "utf8").digest("hex"); + +function stampedDocx(): Buffer { + return buildDocx({ + "word/document.xml": documentXml(), + "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC), + }); +} + +describe("extractDocumentSnapshot", () => { + it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => { + const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" }); + + expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); + expect(snapshot.bookmark).toEqual({ sec1: "hello world" }); + expect(snapshot.properties.custom?.ticket_data).toBe(TICKET_DATA); + expect(snapshot.properties.custom?.HmacSha256).toBe(TICKET_HMAC); + expect(snapshot.verify.HmacSha256).toBe(true); + // exp (unix sec) ฟอร์แมต th-TH เป็นปี พ.ศ. — 2000000000 = 18 พ.ค. ค.ศ. 2033 = พ.ศ. 2576 + expect(typeof snapshot.verify.exp).toBe("string"); + expect(snapshot.verify.exp).toContain("2576"); + // ไม่ส่ง public key = ไม่มี key Ed25519 ใน verify (เหมือน extract ที่ไม่ส่ง public_key) + expect(snapshot.verify.Ed25519).toBeUndefined(); + }); + + it("secret ผิด = HmacSha256 false · ไม่ส่ง secret = ไม่มี key HmacSha256 เลย", () => { + expect(extractDocumentSnapshot(stampedDocx(), { secret: "wrong-secret" }).verify.HmacSha256).toBe(false); + const noSecret = extractDocumentSnapshot(stampedDocx()); + expect(noSecret.verify.HmacSha256).toBeUndefined(); + // exp ยังอ่านได้แม้ไม่ตรวจ HMAC (มาจาก ticket_data ตรง ๆ — ปี พ.ศ. 2576) + expect(noSecret.verify.exp).toContain("2576"); + }); + + it("ไฟล์ไม่มี custom properties = verify ว่าง แต่ cc/bookmark ยังแกะได้", () => { + const bare = buildDocx({ "word/document.xml": documentXml() }); + const snapshot = extractDocumentSnapshot(bare, { secret: "super_secret" }); + + expect(snapshot.verify).toEqual({}); + // getDocProps เติม custom เป็น {} เสมอ (ไม่มี core/app ใน zip จิ๋วนี้) + expect(snapshot.properties).toEqual({ custom: {} }); + expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); + }); + + it("ไฟล์เสีย (ไม่ใช่ ZIP ที่มี word/document.xml) = throw ให้ route ตอบ 400/500 ตามกลุ่ม", () => { + expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow(); + }); +}); diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts index 4de9046..a61ce94 100644 --- a/tests/wordaddin/versioningService.test.ts +++ b/tests/wordaddin/versioningService.test.ts @@ -2,7 +2,8 @@ // versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด: // - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ // uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/ -// templateId — identity มาจาก Bearer หมด), getVersions แปลง DTO, getDownloadLink ใส่ ?format= +// templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง +// 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format= // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) import { createHmac, webcrypto } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -83,12 +84,14 @@ describe("versioningService (real mode)", () => { expect((form.get("file") as File).name).toBe("คำฟ้อง.docx"); expect((form.get("pdf") as File).name).toBe("document.pdf"); expect(form.get("clientVersionId")).toBe("client-vid-1"); - expect(JSON.parse(form.get("fields") as string)).toEqual({ blackno: "ด1" }); expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว // contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย expect(form.has("userId")).toBe(false); expect(form.has("userName")).toBe(false); expect(form.has("templateId")).toBe(false); + // contract 2026-10-30: fields ก็ห้ามส่ง — server แกะจากไฟล์ .docx เอง + // (ตัวเดียวกับ /api/template/extract) แม้ caller จะ pass เข้ามาตาม signature เดิม + expect(form.has("fields")).toBe(false); }); it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => { @@ -105,6 +108,8 @@ describe("versioningService (real mode)", () => { it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => { await signInReal(); + // fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง) + const fields = { cc: { blackno: "ด1" }, bookmark: {}, properties: { custom: {} }, verify: {} }; const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response( @@ -116,7 +121,7 @@ describe("versioningService (real mode)", () => { createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", - fields: { blackno: "ด1" }, + fields, message: null, hasPdf: true, }, @@ -134,7 +139,7 @@ describe("versioningService (real mode)", () => { createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", - fields: { blackno: "ด1" }, + fields, message: null, hasPdf: true, }, diff --git a/wordaddin/models/versionRecord.ts b/wordaddin/models/versionRecord.ts index 3a1087d..a4db131 100644 --- a/wordaddin/models/versionRecord.ts +++ b/wordaddin/models/versionRecord.ts @@ -16,13 +16,36 @@ export interface FieldSnapshotEntry { missing: boolean; } +/** fields ของ version รูปแบบปัจจุบัน — **ก้อนเดียวกับ return ของ `/api/template/extract`** + * ที่ server แกะจากไฟล์ .docx ฝั่ง server ตอนอัปโหลด (2026-10-30) · ค่าวันที่ใน + * properties มาเป็น ISO string ตาม DTO ของกลุ่ม docver */ +export interface ExtractSnapshotFields { + /** ค่า Content Control `{ [tag]: text }` */ + cc: Record; + /** ข้อความใน bookmark `{ [name]: text }` */ + bookmark: Record; + /** document properties (standard/extended/custom) */ + properties: { + standard?: Record; + extended?: Record; + custom?: Record; + }; + /** ผลตรวจ ticket ที่ stamp มากับไฟล์ (exp + HmacSha256 ด้วย secret ฝั่ง server) */ + verify: Record; +} + /** - * snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ: + * snapshot fields ต่อ version — backend เก็บ 3 รูปแบบ: + * - ปัจจุบัน (2026-10-30): ก้อน extract snapshot `{cc, bookmark, properties, verify}` + * เหมือน return ของ /api/template/extract (server แกะจากไฟล์เอง) * - เอกสารผูก template: `{ [fieldKey]: { value, missing } }` * - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }` - * UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน) + * UI ต้อง handle ได้ทั้งหมด (ใช้ readSnapshotField ช่วยอ่าน) */ -export type FieldsSnapshot = Record | Record; +export type FieldsSnapshot = + | ExtractSnapshotFields + | Record + | Record; /** item ของ GET /api/documents/{documentId}/versions */ export interface VersionRecord { @@ -41,7 +64,8 @@ export interface VersionRecord { message: string | null; } -/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */ +/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null + * รูปแบบ extract snapshot (2026-10-30) ค่า CC อยู่ใต้ `cc` — อ่านซ้ำเข้า map ดิบรอบเดียวกัน */ export function readSnapshotField( snapshot: FieldsSnapshot | null, key: string @@ -49,6 +73,13 @@ export function readSnapshotField( if (!snapshot) { return null; } + // Record ธรรมดาไม่ถูก `in` ตัดออกจาก union (มี index signature ครอบทุก key) — เช็คแล้ว + // บอกชนิดตรง ๆ ว่าเป็นก้อน extract snapshot (key "cc" ชนกับ tag ชื่อเดียวกันยอมรับเป็น + // edge case ของการเดารูปจากข้อมูลเก่า) + if ("cc" in snapshot) { + const cc = (snapshot as ExtractSnapshotFields).cc; + return readSnapshotField(cc, key); + } const raw = snapshot[key]; if (raw === undefined) { return null; diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index e25c5db..cd25bc8 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -6,6 +6,8 @@ * - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth * - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text * (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง) + * · ไม่ได้ใช้ตอนสร้าง version แล้ว (2026-10-30 — server แกะจากไฟล์ .docx ด้วยตัว + * เดียวกับ /api/template/extract) เหลือเป็น service API ที่มี test คุม semantics * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts * * กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw** @@ -254,7 +256,10 @@ export function supportedContentControls(context: Word.RequestContext): Word.Con } /** - * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version + * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` — เดิมใช้แนบกับ upload + * version (FR-11) แต่ตั้งแต่ 2026-10-30 server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ + * /api/template/extract (เห็น CC ทุกชนิดเสมอไม่ขึ้นกับเวอร์ชัน WordApi) — เหลือไว้เป็น + * service API ให้เรียกใช้/test ต่อ * - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น * ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text * เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16) diff --git a/wordaddin/services/versionFlow.ts b/wordaddin/services/versionFlow.ts index 951d4a8..11f369c 100644 --- a/wordaddin/services/versionFlow.ts +++ b/wordaddin/services/versionFlow.ts @@ -7,7 +7,9 @@ * - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track * - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด: * stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save() - * → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history + * → ดึงไฟล์ → POST version → คืนผลให้ UI แสดง/refresh history + * (FR-11 fields เดิมอ่านฝั่ง client ตอน capture — 2026-10-30 ยกให้ server แกะจาก + * ไฟล์ .docx ด้วยตัวเดียวกับ /api/template/extract แทน จึงไม่มีขั้นอ่าน fields แล้ว) * * หมายเหตุค่า id ใน metadata: `LastVersionId` และ `` ใน XML part เก็บ * **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload — @@ -21,7 +23,6 @@ import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./docume import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService"; import { generateGuid, - readContentControlFields, readVersionHistoryFromXml, stampTrackedProperties, writeVersionHistoryToXml, @@ -82,7 +83,6 @@ function guessDocumentFileName(): string | undefined { async function uploadCurrentFile( documentId: string, clientVersionId: string, - fields: Record, message?: string, onProgress?: (progress: string) => void, templateId?: string @@ -102,7 +102,7 @@ async function uploadCurrentFile( documentId, file, clientVersionId, - fields, + undefined, guessDocumentFileName(), message, pdf, @@ -139,15 +139,14 @@ export async function startVersioning( { id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" }, ]); - // 3) native save แล้ว capture ไฟล์ + fields + // 3) native save แล้ว capture ไฟล์ (fields ของ version แกะจากไฟล์ฝั่ง server + // ด้วยตัวเดียวกับ /api/template/extract — contract 2026-10-30 ไม่ต้องอ่านเองแล้ว) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); - onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); - const fields = await readContentControlFields(); // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16) onProgress?.("กำลังอัปโหลด version 1…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId); + const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId); // FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" autoSaveService.clearUncommittedSinceVersion(); return { documentId, ...result }; @@ -188,15 +187,13 @@ export async function saveAndVersion( entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user }); await writeVersionHistoryToXml(entries); - // 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11) + // 3) native save → capture ไฟล์ปัจจุบัน (fields แกะฝั่ง server จากไฟล์ที่อัปโหลด — 2026-10-30) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); - onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); - const fields = await readContentControlFields(); // 4) upload onProgress?.("กำลังอัปโหลด version ใหม่…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress); + const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress); // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) if (result.versionNumber !== predictedNumber) { diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index f8fb51a..0aabf10 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -10,6 +10,8 @@ * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims * ของ token — ส่งมาใน form = 400) * - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400) + * - **ห้ามส่ง fields ใน FormData เช่นกัน** (contract 2026-10-30) — server แกะค่า Content + * Control จากไฟล์ .docx ที่อัปโหลดเองด้วยตัวเดียวกับ /api/template/extract) * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block) * = ใช้ memory อย่างเดียว ไม่ throw @@ -18,13 +20,14 @@ import * as authService from "./authService"; import { generateGuid } from "./metadataService"; import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient"; -import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; +import { ExtractSnapshotFields, UploadVersionResult, VersionRecord } from "../models/versionRecord"; // --------------------------------------------------------------------------- // ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient) // --------------------------------------------------------------------------- -/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */ +/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 + * fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง) */ interface DocumentVersionDTO { versionId: string; versionNumber: number; @@ -32,14 +35,16 @@ interface DocumentVersionDTO { createdByUserId: string; createdByUserName: string; contentHash: string; - fields: Record | null; + fields: ExtractSnapshotFields | null; message: string | null; hasPdf: boolean; } /** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST) * + FR-13: message คือ commit message ทางเลือก - * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */ + * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) + * + fields ก็เก็บไว้ตาม signature เดิมเช่นกัน — ไม่ส่งถึง server (contract 2026-10-30: + * server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ /api/template/extract ส่งมา = 400) */ export async function uploadVersion( documentId: string, file: Blob, @@ -52,6 +57,7 @@ export async function uploadVersion( ): Promise { if (!authService.isDemoMode()) { void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token) + void fields; // contract 2026-10-30 — ห้ามส่ง (server แกะจากไฟล์เอง ดูด้านบน) const form = new FormData(); form.append("file", file, fileName ?? "document.docx"); // pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF @@ -61,9 +67,6 @@ export async function uploadVersion( if (clientVersionId) { form.append("clientVersionId", clientVersionId); } - if (fields && Object.keys(fields).length > 0) { - form.append("fields", JSON.stringify(fields)); - } const trimmedMessage = message?.trim(); if (trimmedMessage) { form.append("message", trimmedMessage);