diff --git a/CLAUDE.md b/CLAUDE.md
index f7d1fe1..4e15d3e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@
- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`)
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6)
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
-- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) ส่วน API ยังเป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้)
+- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิง `/api/documents/*` ผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo`) (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้)
- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น)
- mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
@@ -58,7 +58,7 @@ lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่า
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()` เห็นทุกชนิด, ห้าม 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) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
+wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService + 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) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = 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 คุมทั้งสองโหมด)
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 ใช้ร่วม)
diff --git a/components/wordaddin/VersionHistoryPanel.tsx b/components/wordaddin/VersionHistoryPanel.tsx
index 7755035..930a4d1 100644
--- a/components/wordaddin/VersionHistoryPanel.tsx
+++ b/components/wordaddin/VersionHistoryPanel.tsx
@@ -135,7 +135,7 @@ export function VersionHistoryPanel({
) : null}
- {formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
+ {formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId}
);
diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md
index 12fda16..a6b8f62 100644
--- a/docs/PLAN-ADDIN-AUTH.md
+++ b/docs/PLAN-ADDIN-AUTH.md
@@ -209,7 +209,6 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs [opti
## 12. Out of scope
-- ปลด mock `versioningService` (ยัง mock ต่อ — token/flow ที่ทำรอบนี้พร้อมใช้วันปลด)
-- ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid) ของกลุ่ม docver —
- เป็นเรื่องของวันปลด mock (token ผูก fid แต่ mock ยังเรียกด้วย GUID — ไม่กระทบ auth)
+- ~~ปลด mock `versioningService`~~ (ทำแล้ว 2026-10-27 — `versioningService` ยิงกลุ่ม docver จริงผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo` ดู `docs/WORDADDIN-DEV.md`)
+- ~~ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid)~~ (ยุติแล้ว 2026-10-27 — เอกสารระบุด้วย fid ใน token ฝั่งเดียว; พารามิเตอร์ documentId ของ versioningService เหลือตาม signature เดิมไม่ส่งถึง server)
- UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)
diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md
index 652097f..3f9769f 100644
--- a/docs/PLAN-DOCVER.md
+++ b/docs/PLAN-DOCVER.md
@@ -1,7 +1,8 @@
# PLAN-DOCVER — Document Versioning API (stand-alone)
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
-> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
+> รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService`
+> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`)
## 0. บริบท/เป้าหมาย
@@ -225,6 +226,6 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET
## 8. Out of scope รอบนี้
-- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
+- ~~ไม่แต้ `wordaddin/` หรือ client ใด ๆ~~ (ปลด mock แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง")
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md
index bb97631..301bf33 100644
--- a/docs/WORDADDIN-DEV.md
+++ b/docs/WORDADDIN-DEV.md
@@ -4,7 +4,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
-สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) ส่วน **API ยังเป็น mock** (version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
+สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer จาก ticket: Save & Version ขึ้น MinIO จริง ดาวน์โหลดเปิด presigned URL ในเบราว์เซอร์; เหลือ mock เฉพาะโหมด `?addindemo` เพื่อดู UI ใน browser โดยไม่ต้องมี backend — ดูหัว "การรันกับ backend จริง")
## ไฟล์หลัก
@@ -76,11 +76,14 @@ node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place
หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์
-## การสลับกลับของจริง (วันหน้า)
+## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27)
-- **Auth:** ไม่มี login ให้สลับแล้ว — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
-- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` (contract ปัจจุบันของกลุ่ม docver สรุปไว้ใน docblock ของไฟล์นั้น + docs/PLAN-DOCVER.md — identity/docId แกะจาก Bearer token ทั้งหมด)
-- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/`
+- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
+- **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS)
+ - **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400)
+- **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้)
+- **mock เหลือเฉพาะโหมด `?addindemo`** (dev-only — ดู UI ใน browser ไม่ต้องมี backend: version เก็บในเครื่อง metadata ใน localStorage `wordaddin-mock-versions`, download-link คืน about:blank — เคลียร์ด้วยการลบ key นี้หรือ clear storage ของ origin)
+- ชุด unit test ของ services: ต้นฉบับ jest อยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` — ฝั่งนี้เขียนใหม่เป็น vitest เฉพาะส่วนที่เป็นโค้ดใหม่ (ticket auth + versioningService สองโหมด ที่ `tests/wordaddin/`)
## Docker
diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md
index d47ba2a..7739cb5 100644
--- a/docs/addin-ui-flow.md
+++ b/docs/addin-ui-flow.md
@@ -257,7 +257,7 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน
ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md)
-> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) ส่วน API ทั้งสามข้างบนยังเป็น in-memory mock (`wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs`
+> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs`
---
diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts
new file mode 100644
index 0000000..b89ae20
--- /dev/null
+++ b/tests/wordaddin/versioningService.test.ts
@@ -0,0 +1,197 @@
+// @vitest-environment jsdom
+// 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=
+// - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย)
+import { createHmac, webcrypto } from "node:crypto";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import * as authService from "@/wordaddin/services/authService";
+import * as ticketService from "@/wordaddin/services/ticketService";
+import {
+ getDownloadLink,
+ getVersions,
+ uploadVersion,
+} from "@/wordaddin/services/versioningService";
+
+const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้ — apiClient แนบตรง ๆ ไม่ decode
+
+/** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */
+async function signInReal(): Promise {
+ vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle
+ const ticket = {
+ exp: Math.floor(Date.now() / 1000) + 300,
+ fid: "REAL_FID_001",
+ uid: "oom",
+ uname: "สรวิชญ์",
+ };
+ const ticketData = JSON.stringify(ticket);
+ const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({ access_token: TOKEN, token_type: "Bearer", expires_in: 3600 }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ ),
+ );
+ const result = await authService.signInWithTicket({ ticketData, ticketHmac });
+ expect(result.ok, JSON.stringify(result)).toBe(true);
+ vi.unstubAllGlobals();
+}
+
+describe("versioningService (real mode)", () => {
+ it("uploadVersion สร้าง multipart ตรง contract + แนบ Bearer ผ่าน apiClient + parse ผล", async () => {
+ await signInReal();
+ const fetchMock = vi.fn(
+ async (_url: string, _init?: RequestInit) =>
+ new Response(
+ JSON.stringify({ hasPdf: true, versionId: "v-1", versionNumber: 3 }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const docx = new Blob(["PK\x03\x04 docx"], {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ });
+ const pdf = new Blob(["%PDF-"], { type: "application/pdf" });
+ const result = await uploadVersion(
+ "doc-guid-ไม่ส่งถึง-server",
+ docx,
+ "client-vid-1",
+ { blackno: "ด1" },
+ "คำฟ้อง.docx",
+ " แก้ไขย่อหน้า 3 ",
+ pdf,
+ "T12-ไม่ส่งถึง-server",
+ );
+
+ expect(result).toEqual({ hasPdf: true, versionId: "v-1", versionNumber: 3 });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ // same-origin ผ่าน withBasePath (ใน vitest env NEXT_PUBLIC_BASE_PATH ว่าง — Next inline ตอน build
+ // เท่านั้น จึง assert เฉพาะ path ที่อยู่หลัง basePath)
+ expect(url.endsWith("/api/documents/versions")).toBe(true);
+ expect(init.method).toBe("POST");
+ expect((init.headers as Headers).get("authorization")).toBe(`Bearer ${TOKEN}`);
+ const form = init.body as FormData;
+ expect(form.get("file")).toBeInstanceOf(File);
+ 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);
+ });
+
+ it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => {
+ await signInReal();
+ const fetchMock = vi.fn(
+ async (_url: string, _init?: RequestInit) =>
+ new Response(JSON.stringify({ hasPdf: false, versionId: "v-2", versionNumber: 1 }), { status: 200 }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ await uploadVersion("d", new Blob(["PK"]), "cv", undefined, undefined, undefined, new Blob([]));
+ const form = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
+ expect((form.body as FormData).has("pdf")).toBe(false);
+ });
+
+ it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => {
+ await signInReal();
+ const fetchMock = vi.fn(
+ async (_url: string, _init?: RequestInit) =>
+ new Response(
+ JSON.stringify([
+ {
+ versionId: "v-9",
+ versionNumber: 9,
+ createdAt: "2026-10-27T05:00:00Z",
+ createdByUserId: "oom",
+ createdByUserName: "สรวิชญ์",
+ contentHash: "ab",
+ fields: { blackno: "ด1" },
+ message: null,
+ hasPdf: true,
+ },
+ ]),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const rows = await getVersions("anything");
+ expect(rows).toEqual([
+ {
+ versionId: "v-9",
+ versionNumber: 9,
+ createdAt: "2026-10-27T05:00:00Z",
+ createdByUserId: "oom",
+ createdByUserName: "สรวิชญ์",
+ contentHash: "ab",
+ fields: { blackno: "ด1" },
+ message: null,
+ hasPdf: true,
+ },
+ ]);
+ });
+
+ it("getDownloadLink ใส่ ?format=pdf · format docx/ไม่ส่ง = ไม่มี query", async () => {
+ await signInReal();
+ const fetchMock = vi.fn(
+ async (_url: string, _init?: RequestInit) =>
+ new Response(JSON.stringify({ url: "http://minio/x", expiresAt: "2026-10-27T06:00:00Z" }), { status: 200 }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ await getDownloadLink("d", "v-9", "pdf");
+ expect(String(fetchMock.mock.calls[0][0]).endsWith("/api/documents/versions/v-9/download-link?format=pdf")).toBe(true);
+ await getDownloadLink("d", "v-9");
+ expect(String(fetchMock.mock.calls[1][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true);
+ await getDownloadLink("d", "v-9", "docx");
+ expect(String(fetchMock.mock.calls[2][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true);
+ });
+
+ it("โดน 401 แล้ว renew ไม่สำเร็จ = AuthRequiredError (ไม่ใช่ error อื่น)", async () => {
+ await signInReal();
+ // fetch ตอบ 401 ทุกอัน (ทั้ง request หลักและการ renew token จาก ticket)
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 401 })));
+ await expect(getVersions("d")).rejects.toMatchObject({ name: "AuthRequiredError" });
+ });
+});
+
+// โหมด demo ไว้ท้ายสุด — signInForDemo เปิด flag ค้างใน module จนกว่าจะ sign-in จริง
+describe("versioningService (demo mode = mock)", () => {
+ it("signInForDemo แล้วใช้ mock ในเครื่อง — ไม่ยิง network และ persist ตาม documentId", async () => {
+ authService.signInForDemo();
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const first = await uploadVersion("demo-doc", new Blob(["PK"]), "cv1", undefined, undefined, "vแรก");
+ const second = await uploadVersion("demo-doc", new Blob(["PK2"]), "cv2");
+ expect(first.versionNumber).toBe(1);
+ expect(second.versionNumber).toBe(2);
+
+ const rows = await getVersions("demo-doc");
+ expect(rows.map((row) => row.versionNumber)).toEqual([2, 1]); // ใหม่ → เก่า
+ expect(rows[0].message).toBeNull();
+ expect(rows[1].message).toBe("vแรก");
+
+ const link = await getDownloadLink("demo-doc", rows[0].versionId, "pdf");
+ expect(link.url).toBe("about:blank");
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(window.localStorage.getItem("wordaddin-mock-versions")).toBeTruthy();
+ });
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ ticketService.clearSession();
+ authService.signInForDemo(); // รีเซ็ต authService ให้มี username เสมอ (เคสถัดไป sign-in ใหม่เอง)
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
diff --git a/wordaddin/models/versionRecord.ts b/wordaddin/models/versionRecord.ts
index 7af9352..3a1087d 100644
--- a/wordaddin/models/versionRecord.ts
+++ b/wordaddin/models/versionRecord.ts
@@ -32,6 +32,8 @@ export interface VersionRecord {
/** ISO-8601 */
createdAt: string;
createdByUserId: string;
+ /** ชื่อผู้สร้าง — ส่วนเสริมของ DTO กลุ่ม docver (mock/demo อาจไม่มี → UI fallback เป็น userId) */
+ createdByUserName?: string;
/** SHA256 hex */
contentHash: string;
fields: FieldsSnapshot | null;
diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts
index 5db4122..0b4476e 100644
--- a/wordaddin/services/apiClient.ts
+++ b/wordaddin/services/apiClient.ts
@@ -1,4 +1,5 @@
import { config } from "../config";
+import { withBasePath } from "@/utils/base-path";
import * as authService from "./authService";
/**
@@ -36,8 +37,15 @@ export interface ApiRequestOptions {
body?: BodyInit | null;
}
+/**
+ * path เต็มเสมอ — ไม่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` = same-origin ผ่าน withBasePath
+ * (ได้ /docedit/api/… ถูกต้องตาม invariant #12 — API ของกลุ่ม docver อยู่แอปนี้เอง)
+ * · ตั้งค่า = ชี้ backend คนละ origin (ต้องรวม basePath แล้ว เช่น https://host/docedit — ใช้ตอน
+ * deploy add-in กับ API คนละตัว) และต้องเป็น HTTPS เสมอ (webview ของ Word บล็อก http)
+ */
function toUrl(path: string): string {
- return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
+ if (path.startsWith("http")) return path;
+ return config.apiBaseUrl ? `${config.apiBaseUrl}${path}` : withBasePath(path);
}
async function doFetch(
diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts
index fe92661..6904876 100644
--- a/wordaddin/services/authService.ts
+++ b/wordaddin/services/authService.ts
@@ -27,12 +27,20 @@ let username: string | null = null;
let currentFid: string | null = null;
/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */
let currentTicket: { data: string; hmac: string } | null = null;
+/** โหมด ?addindemo (dev-only) — เปิดโดย signInForDemo เท่านั้น */
+let demoMode = false;
/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
export function signInForDemo(): void {
username = config.mockUsername;
currentFid = null;
currentTicket = null;
+ demoMode = true;
+}
+
+/** โหมด ?addindemo หรือเปล่า — versioningService ใช้ mock แทน backend จริงเฉพาะโหมดนี้ */
+export function isDemoMode(): boolean {
+ return demoMode;
}
/**
@@ -139,6 +147,7 @@ export async function signInWithTicket(input: {
currentFid = ticket.fid;
currentTicket = { data: ticketData, hmac: ticketHmac };
username = ticket.uname;
+ demoMode = false; // sign-in จริงเคลียร์โหมด demo (ถ้าเคยเปิดค้างใน page เดียวกัน)
return { ok: true };
}
diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts
index 506130a..36a97dc 100644
--- a/wordaddin/services/versioningService.ts
+++ b/wordaddin/services/versioningService.ts
@@ -1,26 +1,146 @@
/**
- * MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
- * ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
+ * Version API ฝั่ง client — **ของจริง** (กลุ่ม docver /api/documents/* ผ่าน apiClient
+ * ซึ่งแนบ Bearer แล้ว — contract ตาม docs/PLAN-DOCVER.md) · เหลือ mock เฉพาะโหมด
+ * `?addindemo` (dev-only ดู UI ใน browser ไม่ต้องมี backend/MinIO — authService.isDemoMode)
*
- * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
- * วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
- * - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
- * - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ
- * POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี
- * segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId
- * (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST
- * /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว
- * - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
- * ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
- * การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
- * ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
- * ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
+ * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ตามต้นฉบับ
+ * D:\word-addin-poc\addin\src\services\versioningService.ts — versionFlow เรียกต่อได้เดิม
+ * - **เอกสารถูกระบุด้วย fid ใน Bearer token** (ไม่ใช่ documentId GUID ที่ stamp ในไฟล์ —
+ * 2026-10-26 contract ถอด {docId} ออกจาก URL ทุก endpoint) พารามิเตอร์ documentId จึงเหลือ
+ * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims
+ * ของ token — ส่งมาใน form = 400)
+ * - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400)
+ * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่
+ * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block)
+ * = ใช้ memory อย่างเดียว ไม่ throw
*/
import * as authService from "./authService";
import { generateGuid } from "./metadataService";
+import { apiGetJson, apiSendExpectJson } from "./apiClient";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
+// ---------------------------------------------------------------------------
+// ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient)
+// ---------------------------------------------------------------------------
+
+/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */
+interface DocumentVersionDTO {
+ versionId: string;
+ versionNumber: number;
+ createdAt: string;
+ createdByUserId: string;
+ createdByUserName: string;
+ contentHash: string;
+ fields: Record | null;
+ message: string | null;
+ hasPdf: boolean;
+}
+
+/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST)
+ * + FR-13: message คือ commit message ทางเลือก
+ * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */
+export async function uploadVersion(
+ documentId: string,
+ file: Blob,
+ clientVersionId: string,
+ fields?: Record,
+ fileName?: string,
+ message?: string,
+ pdf?: Blob,
+ templateId?: string
+): Promise {
+ if (!authService.isDemoMode()) {
+ void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token)
+ const form = new FormData();
+ form.append("file", file, fileName ?? "document.docx");
+ // pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF
+ if (pdf && pdf.size > 0) {
+ form.append("pdf", pdf, "document.pdf");
+ }
+ 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);
+ }
+ if (fileName) {
+ form.append("fileName", fileName);
+ }
+ return apiSendExpectJson(
+ "/api/documents/versions",
+ { method: "POST", body: form },
+ "อัปโหลด version ไม่สำเร็จ",
+ );
+ }
+ return uploadVersionMock(documentId, file, clientVersionId, fields, message, pdf);
+}
+
+/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ)
+ * docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง */
+export async function getVersions(documentId: string): Promise {
+ if (!authService.isDemoMode()) {
+ const rows = await apiGetJson(
+ "/api/documents/versions",
+ "โหลด version history ไม่สำเร็จ",
+ );
+ // server เรียงใหม่ → เก่าอยู่แล้ว — แปลงเป็น VersionRecord ตามที่ UI ใช้
+ return rows.map((row) => ({
+ versionId: row.versionId,
+ versionNumber: row.versionNumber,
+ createdAt: row.createdAt,
+ createdByUserId: row.createdByUserId,
+ createdByUserName: row.createdByUserName,
+ contentHash: row.contentHash,
+ fields: row.fields && Object.keys(row.fields).length > 0 ? row.fields : null,
+ message: row.message,
+ hasPdf: row.hasPdf,
+ }));
+ }
+ return getVersionsMock(documentId);
+}
+
+export interface VersionDownloadLink {
+ url: string;
+ /** ISO-8601 เวลาที่ URL หมดอายุ */
+ expiresAt: string;
+}
+
+/**
+ * FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
+ * (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
+ * URL เป็น MinIO presigned GET (อายุ env DOCVER_LINK_TTL_SECONDS default 900 วิ) —
+ * โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้
+ */
+export async function getDownloadLink(
+ documentId: string,
+ versionId: string,
+ format?: "docx" | "pdf"
+): Promise {
+ if (!authService.isDemoMode()) {
+ void documentId; // เอกสารมาจาก fid ใน token — versionId เช็คคู่กับ docId ฝั่ง server
+ const query = format && format !== "docx" ? `?format=${encodeURIComponent(format)}` : "";
+ return apiGetJson(
+ `/api/documents/versions/${encodeURIComponent(versionId)}/download-link${query}`,
+ "ขอลิงก์ดาวน์โหลดไม่สำเร็จ",
+ );
+ }
+ return getDownloadLinkMock();
+}
+
+/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
+export function newClientVersionId(): string {
+ return generateGuid();
+}
+
+// ---------------------------------------------------------------------------
+// Mock — เฉพาะโหมด ?addindemo (dev-only)
+// ---------------------------------------------------------------------------
+
interface StoredVersion {
record: VersionRecord;
docxBlob: Blob | null;
@@ -78,22 +198,15 @@ async function mockContentHash(file: Blob): Promise {
}
}
-/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
- * + FR-13: message คือ commit message ทางเลือก
- * + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
-export async function uploadVersion(
+async function uploadVersionMock(
documentId: string,
file: Blob,
clientVersionId: string,
fields?: Record,
- fileName?: string,
message?: string,
- pdf?: Blob,
- templateId?: string
+ pdf?: Blob
): Promise {
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
- void fileName;
- void templateId;
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
const versions = store.get(documentId) ?? [];
@@ -117,8 +230,7 @@ export async function uploadVersion(
return { hasPdf, versionId: record.versionId, versionNumber };
}
-/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
-export async function getVersions(documentId: string): Promise {
+async function getVersionsMock(documentId: string): Promise {
await delay(200);
const versions = store.get(documentId) ?? [];
return versions
@@ -127,33 +239,7 @@ export async function getVersions(documentId: string): Promise
.map((item) => ({ ...item.record }));
}
-export interface VersionDownloadLink {
- url: string;
- /** ISO-8601 เวลาที่ URL หมดอายุ */
- expiresAt: string;
-}
-
-/**
- * FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
- * (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
- * mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
- * context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
- * ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
- * — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
- */
-export async function getDownloadLink(
- documentId: string,
- versionId: string,
- format?: "docx" | "pdf"
-): Promise {
- void documentId;
- void versionId;
- void format;
+async function getDownloadLinkMock(): Promise {
await delay(150);
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
}
-
-/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
-export function newClientVersionId(): string {
- return generateGuid();
-}