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(); -}