Compare commits
30 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4200176239 | ||
|
|
7cd8939789 | ||
|
|
cffc571da5 | ||
|
|
69d83ea493 | ||
|
|
8ebe5f8644 | ||
|
|
50af6c10fa | ||
|
|
a3d14fd58d | ||
|
|
6ce071ee14 | ||
|
|
ab62a2b3e2 | ||
|
|
c06da83f92 | ||
|
|
86353cbe57 | ||
|
|
0b2c9e6343 | ||
|
|
aad6f155fe | ||
|
|
98b370c925 | ||
|
|
ac6c0dc71e | ||
|
|
280cc2da40 | ||
|
|
949861ba86 | ||
|
|
ed8493004c | ||
|
|
765946f0d5 | ||
|
|
6daee15851 | ||
|
|
5917c0532c | ||
|
|
a172b111a3 | ||
|
|
1aeaae38f1 | ||
|
|
4bcd01a95f | ||
|
|
808233191d | ||
|
|
2b70a50c62 | ||
|
|
dc851fbb73 | ||
|
|
33670d0b18 | ||
|
|
c401f4deb7 | ||
|
|
7a146d48be |
92 changed files with 27233 additions and 1200 deletions
49
.env.example
49
.env.example
|
|
@ -5,3 +5,52 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
|||
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
|
||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
|
||||
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||
DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit
|
||||
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||
EXTERNAL_API_KEY='secret-api-key-1234'
|
||||
# ที่เก็บไฟล์เทมเพลต(docx)
|
||||
TMPL_PATH='./public/tmpl'
|
||||
|
||||
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
|
||||
SECRET="super_secret"
|
||||
PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"
|
||||
PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546"
|
||||
# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data)
|
||||
TICKET_SEC=300
|
||||
|
||||
|
||||
|
||||
# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
|
||||
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
|
||||
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
|
||||
# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ —
|
||||
# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# MINIO_BUCKET=docver
|
||||
# DOCVER_LINK_TTL_SECONDS=900
|
||||
|
||||
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
|
||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
|
||||
DOCVER_API_KEY=
|
||||
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
|
||||
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
|
||||
DOCVER_TOKEN_SECRET=
|
||||
# DOCVER_TOKEN_TTL_SECONDS=3600
|
||||
|
||||
# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token
|
||||
# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp
|
||||
# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง
|
||||
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
||||
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
||||
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
||||
# ADDIN_TICKET_SECRET=super_secret
|
||||
# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง
|
||||
# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน)
|
||||
# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -31,5 +31,14 @@ next-env.d.ts
|
|||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||
public/note/uploads/
|
||||
|
||||
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
|
||||
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
|
||||
public/wordaddin/office-js/
|
||||
|
||||
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
|
||||
/certs/
|
||||
|
||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||
docker-compose.test.yml
|
||||
|
||||
certificates
|
||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -12,6 +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` ใช้ไม่ได้)
|
||||
- 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)
|
||||
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`)
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath`
|
||||
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
||||
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
||||
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
||||
|
|
@ -28,16 +29,20 @@
|
|||
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
|
||||
10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
|
||||
11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน)
|
||||
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
|
||||
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
|
||||
|
||||
## โครงสร้างไฟล์ (ตามแผน)
|
||||
|
||||
```
|
||||
docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket)
|
||||
docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 4 endpoint (เพิ่ม POST /token ขอ access_token — Bearer บังคับทุก endpoint), ดีไซน์ตาราง, env MINIO_* + DOCVER_API_KEY/DOCVER_TOKEN_SECRET/DOCVER_TOKEN_TTL_SECONDS ทั้งหมด
|
||||
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
||||
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
||||
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
|
||||
scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp <unix|+sec>/--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9)
|
||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon)
|
||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||
|
|
@ -45,7 +50,15 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย
|
|||
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม)
|
||||
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
||||
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + 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") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + 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/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
|
||||
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ 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`)
|
||||
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 ใช้ร่วม)
|
||||
|
|
@ -54,6 +67,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr
|
|||
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
|
||||
utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure)
|
||||
utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12)
|
||||
utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts
|
||||
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
|
||||
utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05)
|
||||
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
|
||||
|
|
@ -68,12 +82,17 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
|
|||
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
||||
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
|
||||
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
|
||||
app/api/documents/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/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)
|
||||
app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note)
|
||||
app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||
app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
||||
app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client)
|
||||
app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น)
|
||||
components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + ตั้ง session mock (`signInForDemo`) + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง; เส้นทางจริงไม่มี demo จะจบที่หน้า no-access เพราะอ่าน properties ไม่ได้)
|
||||
components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน ticket_data/HmacSha256 จากไฟล์ผ่าน signInWithTicket ไม่ผ่าน = no-access แยกข้อความตามสาเหตุ · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)**
|
||||
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
|
||||
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้
|
||||
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -19,7 +19,7 @@
|
|||
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` |
|
||||
|
||||
## ขั้นตอน Setup จากศูนย์
|
||||
|
||||
|
|
@ -31,17 +31,16 @@ npm install
|
|||
|
||||
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
|
||||
|
||||
### 2. รัน PostgreSQL container
|
||||
### 2. รัน PostgreSQL + MinIO ด้วย docker compose
|
||||
|
||||
```bash
|
||||
docker run -d --name judes-note-db \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
|
||||
-p 54322:5432 \
|
||||
-v judes-note-db-data:/var/lib/postgresql/data \
|
||||
postgres:17-alpine
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
|
||||
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio`
|
||||
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
|
||||
|
||||
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
|
||||
|
||||
### 3. สร้างไฟล์ `.env`
|
||||
|
||||
|
|
@ -50,6 +49,12 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
|||
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2)
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
```
|
||||
|
||||
(เทียบกับ `.env.example`)
|
||||
|
|
@ -125,6 +130,28 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
## API จัด version เอกสาร (docver — stand-alone)
|
||||
|
||||
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
|
||||
|
||||
```bash
|
||||
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
|
||||
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
|
||||
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
|
||||
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
|
||||
|
||||
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
|
||||
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
|
||||
```
|
||||
|
||||
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
|
||||
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
|
||||
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| อาการ | สาเหตุ/ทางแก้ |
|
||||
|
|
@ -132,7 +159,8 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
|
||||
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
|
||||
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
|
||||
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
|
||||
|
||||
## หมายเหตุทางเทคนิค
|
||||
|
|
@ -148,11 +176,14 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
||||
db/index.ts postgres.js + drizzle (prepare: false)
|
||||
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
|
||||
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
|
||||
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
|
||||
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
||||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
|
||||
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||
|
|
|
|||
97
app/api/documents/token/route.ts
Normal file
97
app/api/documents/token/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
|
||||
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
||||
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
||||
// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
||||
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
|
||||
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in)
|
||||
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
||||
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
|
||||
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
||||
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
||||
import { docverError } from "@/lib/document-versioning";
|
||||
import {
|
||||
addinTicketSecret,
|
||||
parseTicketTokenBody,
|
||||
ticketToDocverClaims,
|
||||
verifyAddinTicket,
|
||||
} from "@/lib/addin-ticket";
|
||||
import {
|
||||
issueDocverToken,
|
||||
parseTokenRequestBody,
|
||||
requireDocverApiKey,
|
||||
type DocverTokenClaims,
|
||||
} from "@/lib/docver-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */
|
||||
const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return request.headers.get("x-api-key") !== null
|
||||
? postWithApiKey(request)
|
||||
: postWithTicket(request);
|
||||
}
|
||||
|
||||
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
||||
async function postWithApiKey(request: Request): Promise<Response> {
|
||||
const apiKeyFail = requireDocverApiKey(request);
|
||||
if (apiKeyFail !== null) return apiKeyFail;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTokenRequestBody(raw);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
return issueToken(parsed.claims);
|
||||
}
|
||||
|
||||
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
||||
async function postWithTicket(request: Request): Promise<Response> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTicketTokenBody(raw);
|
||||
if (!parsed.ok) {
|
||||
// missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน)
|
||||
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
|
||||
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
|
||||
}
|
||||
const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
|
||||
if (!verified.ok) return docverError(TICKET_DENIED, 401);
|
||||
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||
}
|
||||
|
||||
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
||||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
// const secret =
|
||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||
const ttlSeconds =
|
||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
||||
|
||||
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: ttlSeconds,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
|
||||
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
||||
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
|
||||
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
|
||||
import {
|
||||
docverError,
|
||||
parseDownloadFormat,
|
||||
parseVersionIdParam,
|
||||
} from "@/lib/document-versioning";
|
||||
import { requireDocverBearer } from "@/lib/docver-token";
|
||||
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { versionId: string } },
|
||||
) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||
const docId = auth.claims.fid;
|
||||
const versionId = parseVersionIdParam(params.versionId);
|
||||
if (!versionId.ok) return docverError(versionId.error, 400);
|
||||
let format: string | null = null;
|
||||
try {
|
||||
format = new URL(request.url).searchParams.get("format");
|
||||
} catch {
|
||||
return docverError("URL ของ request ไม่ถูกต้อง", 400);
|
||||
}
|
||||
const parsedFormat = parseDownloadFormat(format);
|
||||
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
|
||||
|
||||
try {
|
||||
const row = await findVersionForDoc(docId, versionId.versionId);
|
||||
if (!row) {
|
||||
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||
}
|
||||
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
|
||||
return docverError("version นี้ไม่มีสำเนา PDF", 404);
|
||||
}
|
||||
const link = await presignVersionUrl(row.id, parsedFormat.format);
|
||||
return new Response(JSON.stringify(link), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: สร้าง download link ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
170
app/api/documents/versions/route.ts
Normal file
170
app/api/documents/versions/route.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions
|
||||
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
|
||||
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
|
||||
// Authorization: Bearer ทุก request — identity (userid/username), templateId และ
|
||||
// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด
|
||||
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { toDocumentVersionDTO } from "@/db/schema";
|
||||
import {
|
||||
docverError,
|
||||
DOCVER_MAX_UPLOAD_BYTES,
|
||||
DOCX_CONTENT_TYPE,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseVersionUploadForm,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
import { requireDocverBearer } from "@/lib/docver-token";
|
||||
import {
|
||||
findDocumentByDocId,
|
||||
insertVersionRecord,
|
||||
listVersionsByDocId,
|
||||
putVersionObjects,
|
||||
removeVersionObjects,
|
||||
versionObjectKeys,
|
||||
} from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||
export async function POST(request: Request) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||
const docId = auth.claims.fid;
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น multipart/form-data", 400);
|
||||
}
|
||||
|
||||
const parsed = parseVersionUploadForm(form);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
|
||||
}
|
||||
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
|
||||
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
|
||||
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
|
||||
}
|
||||
|
||||
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
|
||||
const rawPdf = form.get("pdf");
|
||||
let pdf: File | null = null;
|
||||
if (rawPdf instanceof File && rawPdf.size > 0) {
|
||||
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
pdf = rawPdf;
|
||||
}
|
||||
|
||||
let docxBytes: Buffer;
|
||||
let pdfBytes: Buffer | null = null;
|
||||
try {
|
||||
docxBytes = Buffer.from(await file.arrayBuffer());
|
||||
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
|
||||
} catch (err) {
|
||||
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
if (!isDocxBytes(docxBytes)) {
|
||||
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
|
||||
}
|
||||
if (pdfBytes && !isPdfBytes(pdfBytes)) {
|
||||
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
|
||||
const versionId = randomUUID();
|
||||
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
|
||||
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
|
||||
const docxDisplayName =
|
||||
parsed.form.fileName ?? sanitizeFilePartName(file.name);
|
||||
|
||||
try {
|
||||
await putVersionObjects({
|
||||
versionId,
|
||||
docx: { bytes: docxBytes, fileName: docxDisplayName },
|
||||
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
const keys = versionObjectKeys(versionId);
|
||||
let row;
|
||||
try {
|
||||
row = await insertVersionRecord({
|
||||
versionId,
|
||||
docId,
|
||||
clientVersionId: parsed.form.clientVersionId,
|
||||
contentHash,
|
||||
message: parsed.form.message,
|
||||
fields: parsed.form.fields,
|
||||
templateId: auth.claims.template,
|
||||
fileName: docxDisplayName,
|
||||
docxObjectKey: keys.docx,
|
||||
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
||||
docxSize: docxBytes.length,
|
||||
pdfSize: pdfBytes?.length ?? null,
|
||||
userId: auth.claims.userid,
|
||||
userName: auth.claims.username,
|
||||
});
|
||||
} catch (err) {
|
||||
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
||||
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
|
||||
await removeVersionObjects(versionId, pdfBytes !== null);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hasPdf: pdfBytes !== null,
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
||||
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const docId = auth.claims.fid;
|
||||
|
||||
try {
|
||||
const doc = await findDocumentByDocId(docId);
|
||||
if (!doc) {
|
||||
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
|
||||
}
|
||||
const rows = await listVersionsByDocId(doc.id);
|
||||
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: โหลด version history ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
18
app/api/note/external/documents/route.ts
vendored
18
app/api/note/external/documents/route.ts
vendored
|
|
@ -4,6 +4,7 @@ import { db } from "@/db";
|
|||
import { files } from "@/db/schema";
|
||||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||
import { requestOrigin } from "@/utils/request-origin";
|
||||
|
||||
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
|
||||
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
||||
|
|
@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
|||
|
||||
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||
|
||||
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
|
||||
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
|
||||
* x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย
|
||||
* fallback จาก host/protocol ของ request ตรง ๆ */
|
||||
function requestOrigin(req: Request): string {
|
||||
const url = new URL(req.url);
|
||||
const proto =
|
||||
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
|
||||
url.protocol.replace(":", "");
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("host") ??
|
||||
url.host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม
|
||||
// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16)
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const unauthorized = requireExternalApiKey(req);
|
||||
|
|
|
|||
108
app/api/template/download-docx/route.ts
Normal file
108
app/api/template/download-docx/route.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
|
||||
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??"";
|
||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??"";
|
||||
const TMPL_PATH = process.env.TMPL_PATH??"";
|
||||
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
|
||||
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
|
||||
}
|
||||
|
||||
|
||||
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
|
||||
{
|
||||
// console.log("processRequest get:",fid,tid,uid,uname)
|
||||
// console.log(process.cwd())
|
||||
const filePath = path.join(TMPL_PATH,`${fid}.json`);
|
||||
try {
|
||||
if (!fid||!tid||!uid||!uname) {
|
||||
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
|
||||
}
|
||||
|
||||
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
|
||||
const rawJson = await fs.readFile(filePath, 'utf-8');
|
||||
const jsonData = JSON.parse(rawJson);
|
||||
if (!jsonData || typeof jsonData !== 'object' ) {
|
||||
return NextResponse.json({error:'Invalid data format'}, { status: 400 });
|
||||
}
|
||||
|
||||
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!!
|
||||
|
||||
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
|
||||
if(!Object.hasOwn(jsonData, 'info'))
|
||||
jsonData['info']={}
|
||||
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname})
|
||||
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
|
||||
|
||||
//ยิงไปหาระบบ Docedit เพื่อเอา Stream
|
||||
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
|
||||
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': EXTERNAL_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(jsonData),
|
||||
});
|
||||
|
||||
// เช็ค Content-Type และ Status ก่อน
|
||||
const contentType = docxResponse.headers.get('content-type') || '';
|
||||
|
||||
if (!docxResponse.ok || contentType.includes('text/html')) {
|
||||
const errorText = await docxResponse.text();
|
||||
console.error('download-docx:Invalid docx Response:', errorText);
|
||||
|
||||
return NextResponse.json(
|
||||
{error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
|
||||
const stream = docxResponse.body;
|
||||
|
||||
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
|
||||
const download_file = `${fid}_${uid}_xxx`
|
||||
return new NextResponse(stream as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
});
|
||||
} catch (error:any) {
|
||||
if ( error.code === 'ENOENT') {
|
||||
return NextResponse.json(
|
||||
{ error: `Data not found: ${filePath}`},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error(error)
|
||||
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest)
|
||||
{
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const fid = searchParams.get('fid') || '';
|
||||
const tid = searchParams.get('tid') || '';
|
||||
const uid = searchParams.get('uid') || '';
|
||||
const uname = searchParams.get('uname') || '';
|
||||
return await processRequest(fid,tid,uid,uname)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const fid = String(formData.get('fid'))
|
||||
const tid = String(formData.get('tid'))
|
||||
const uid = String(formData.get('uid'))
|
||||
const uname = String(formData.get('uname'))
|
||||
return await processRequest(fid,tid,uid,uname)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { extractContentControls } from "../lib-tmpl";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
|
||||
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
|
||||
// export const runtime = "nodejs";
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
|
@ -14,24 +14,14 @@ export async function POST(request: Request) {
|
|||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const TEST_SECRET = String(formData.get("secret") ?? "");
|
||||
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing DOCX file in field "file".',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
file.type !== DOCX_CONTENT_TYPE &&
|
||||
!file.name.toLowerCase().endsWith(".docx")
|
||||
if (!(file instanceof File) ||
|
||||
(file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "file" must be a .docx file.',
|
||||
},
|
||||
{ status: 400 },
|
||||
|
|
@ -41,7 +31,6 @@ export async function POST(request: Request) {
|
|||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "file" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
|
|
@ -52,18 +41,36 @@ export async function POST(request: Request) {
|
|||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// ใช้ lib-tmpl.ts เดิม
|
||||
const data = extractContentControls(buffer);
|
||||
// const data = extractContentControls(buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
const cc = extractContentControls(buffer);
|
||||
const bookmark = extractBookmark(buffer)
|
||||
const properties = getDocProps(buffer)
|
||||
let verify: Record<string, unknown> ={}
|
||||
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<string, unknown> = {cc,bookmark,properties,verify}
|
||||
return NextResponse.json(returnData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
|
|
|||
101
app/api/template/generate/route.ts
Normal file
101
app/api/template/generate/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// สร้าง docx จาก json
|
||||
// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้
|
||||
// - สร้าง signature/hash จาก json.info.ticket_data
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {readFile} from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
|
||||
const TMPL_PATH = process.env.TMPL_PATH || ''
|
||||
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
|
||||
const SECRET = process.env.SECRET || ''
|
||||
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||
|
||||
if (EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
|
||||
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
|
||||
const apiKey = request.headers.get('x-api-key');
|
||||
|
||||
if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized: Invalid API Key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// รับข้อมูล JSON Payload
|
||||
const templateData:DocumentTemplate = (await request.json());
|
||||
console.log(templateData)
|
||||
// ต้องมี templateData.info.ticket_data เป็น json string เสมอ
|
||||
if (!templateData || !templateData.info ||
|
||||
!templateData.info.ticket_data ||
|
||||
typeof templateData.info.ticket_data !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "template/generate json.info.ticket_data not avaliable",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const ticketObj = JSON.parse(templateData.info.ticket_data)
|
||||
const tid = String(ticketObj.tid)
|
||||
const fid = String(ticketObj.fid)
|
||||
const uid = String(ticketObj.uid)
|
||||
if(!tid||!fid||!uid)
|
||||
{
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "template/generate require json.info.ticket_data.(fid|tid|uid)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//เปิดไฟล์ template
|
||||
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`);
|
||||
let templateBuffer: Buffer = await readFile(tmplPath)
|
||||
|
||||
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);
|
||||
|
||||
// สร้าง ReadableStream ของไฟล์ .docx
|
||||
// ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System
|
||||
// const encoder = new TextEncoder();
|
||||
|
||||
const docxStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(output);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word
|
||||
const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`)
|
||||
return new NextResponse(docxStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${dlFileName}.docx"`,
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
});
|
||||
} catch (error:any) {
|
||||
if ( error.code === 'ENOENT') {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound: Invalid template ID or incorrect storage path'},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('Error generating docx:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate document payload'},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
import PizzaZip from "pizzip";
|
||||
import {
|
||||
DOMParser,
|
||||
XMLSerializer,
|
||||
type Document as XmlDocument,
|
||||
type Element as XmlElement,
|
||||
type Node as XmlNode,
|
||||
type Attr as XmlAttr,
|
||||
} from "@xmldom/xmldom";
|
||||
import xpath from "xpath";
|
||||
|
||||
export interface ExtractedData {
|
||||
[tagName: string]: string;
|
||||
}
|
||||
|
||||
const W_NS =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
const asXpathNode = (node: XmlNode): globalThis.Node =>
|
||||
node as unknown as globalThis.Node;
|
||||
|
||||
function createSelector() {
|
||||
return xpath.useNamespaces({ w: W_NS });
|
||||
}
|
||||
|
||||
/**
|
||||
* อ่าน Content Control (w:sdt) จาก DOCX Buffer
|
||||
* ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ
|
||||
*/
|
||||
export function extractContentControls(
|
||||
docxBuffer: Buffer,
|
||||
): ExtractedData {
|
||||
const zip = new PizzaZip(docxBuffer);
|
||||
const docXmlFile = zip.file("word/document.xml");
|
||||
|
||||
if (!docXmlFile) {
|
||||
throw new Error("Invalid docx file: word/document.xml not found.");
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
||||
const select = createSelector();
|
||||
const result: ExtractedData = {};
|
||||
|
||||
const sdtNodes = select(
|
||||
"//w:sdt",
|
||||
asXpathNode(doc),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
sdtNodes.forEach((sdt) => {
|
||||
const tagAttr = select(
|
||||
"./w:sdtPr/w:tag/@w:val",
|
||||
asXpathNode(sdt),
|
||||
true,
|
||||
) as unknown as XmlAttr | undefined;
|
||||
|
||||
const tagName = tagAttr?.value;
|
||||
|
||||
if (!tagName) return;
|
||||
|
||||
const sdtContent = select(
|
||||
"./w:sdtContent",
|
||||
asXpathNode(sdt),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (!sdtContent) {
|
||||
result[tagName] = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = select(
|
||||
".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym",
|
||||
asXpathNode(sdtContent),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
let extractedText = "";
|
||||
|
||||
nodes.forEach((node) => {
|
||||
switch (node.nodeName) {
|
||||
case "w:p":
|
||||
if (
|
||||
extractedText.length > 0 &&
|
||||
!extractedText.endsWith("\n")
|
||||
) {
|
||||
extractedText += "\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case "w:t":
|
||||
extractedText += node.textContent || "";
|
||||
break;
|
||||
|
||||
case "w:tab":
|
||||
extractedText += "\t";
|
||||
break;
|
||||
|
||||
case "w:br":
|
||||
extractedText += "\n";
|
||||
break;
|
||||
|
||||
case "w:noBreakHyphen":
|
||||
extractedText += "\u2011";
|
||||
break;
|
||||
|
||||
case "w:softHyphen":
|
||||
extractedText += "\u00ad";
|
||||
break;
|
||||
|
||||
case "w:sym": {
|
||||
const charAttr = node.getAttribute("w:char");
|
||||
if (charAttr) {
|
||||
const charCode = Number.parseInt(charAttr, 16);
|
||||
extractedText += String.fromCharCode(charCode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result[tagName] = extractedText;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type SdtControlType = "plain" | "rich" | "other";
|
||||
type SdtStructure = "block" | "inline";
|
||||
|
||||
function detectSdtControlType(
|
||||
sdt: XmlElement,
|
||||
select: xpath.XPathSelect,
|
||||
): { type: SdtControlType; multiLine: boolean } {
|
||||
const sdtPr = select(
|
||||
"./w:sdtPr",
|
||||
asXpathNode(sdt),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (!sdtPr) {
|
||||
return { type: "other", multiLine: false };
|
||||
}
|
||||
|
||||
const textEl = select(
|
||||
"./w:text",
|
||||
asXpathNode(sdtPr),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (textEl) {
|
||||
const multiLineAttr = select(
|
||||
"./@w:multiLine",
|
||||
asXpathNode(textEl),
|
||||
true,
|
||||
) as unknown as XmlAttr | undefined;
|
||||
|
||||
return {
|
||||
type: "plain",
|
||||
multiLine:
|
||||
multiLineAttr?.value === "1" ||
|
||||
multiLineAttr?.value === "true",
|
||||
};
|
||||
}
|
||||
|
||||
const richTextEl = select(
|
||||
"./w:richText",
|
||||
asXpathNode(sdtPr),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (richTextEl) {
|
||||
return { type: "rich", multiLine: true };
|
||||
}
|
||||
|
||||
const otherTypeTags = [
|
||||
"w:date",
|
||||
"w:dropDownList",
|
||||
"w:comboBox",
|
||||
"w:checkbox",
|
||||
"w:citation",
|
||||
"w:group",
|
||||
"w:docPartObj",
|
||||
"w:docPartList",
|
||||
];
|
||||
|
||||
for (const tag of otherTypeTags) {
|
||||
const found = select(
|
||||
`./${tag}`,
|
||||
asXpathNode(sdtPr),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (found) {
|
||||
return { type: "other", multiLine: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "rich", multiLine: true };
|
||||
}
|
||||
|
||||
function detectSdtStructure(
|
||||
sdtContent: XmlElement,
|
||||
select: xpath.XPathSelect,
|
||||
): SdtStructure {
|
||||
const directP = select(
|
||||
"./w:p",
|
||||
asXpathNode(sdtContent),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
return directP.length > 0 ? "block" : "inline";
|
||||
}
|
||||
|
||||
function buildRunChildren(
|
||||
doc: XmlDocument,
|
||||
text: string,
|
||||
): XmlElement[] {
|
||||
const nodes: XmlElement[] = [];
|
||||
const tokens = text.split(/(\t|\n)/);
|
||||
|
||||
tokens.forEach((token) => {
|
||||
if (token === "") return;
|
||||
|
||||
if (token === "\t") {
|
||||
nodes.push(doc.createElementNS(W_NS, "w:tab"));
|
||||
} else if (token === "\n") {
|
||||
nodes.push(doc.createElementNS(W_NS, "w:br"));
|
||||
} else {
|
||||
const t = doc.createElementNS(W_NS, "w:t");
|
||||
t.setAttribute("xml:space", "preserve");
|
||||
t.textContent = token;
|
||||
nodes.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const t = doc.createElementNS(W_NS, "w:t");
|
||||
t.setAttribute("xml:space", "preserve");
|
||||
t.textContent = "";
|
||||
nodes.push(t);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function buildRun(
|
||||
doc: XmlDocument,
|
||||
text: string,
|
||||
rPr?: XmlElement,
|
||||
): XmlElement {
|
||||
const run = doc.createElementNS(W_NS, "w:r");
|
||||
|
||||
if (rPr) {
|
||||
run.appendChild(rPr.cloneNode(true));
|
||||
}
|
||||
|
||||
buildRunChildren(doc, text).forEach((node) =>
|
||||
run.appendChild(node),
|
||||
);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
function insertInlineContent(
|
||||
doc: XmlDocument,
|
||||
sdtContent: XmlElement,
|
||||
select: xpath.XPathSelect,
|
||||
text: string,
|
||||
allowLineBreak: boolean,
|
||||
): void {
|
||||
const safeText = allowLineBreak
|
||||
? text
|
||||
: text.replace(/\n/g, " ");
|
||||
|
||||
const existingRuns = select(
|
||||
"./w:r",
|
||||
asXpathNode(sdtContent),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
const firstRPr =
|
||||
existingRuns.length > 0
|
||||
? (select(
|
||||
"./w:rPr",
|
||||
asXpathNode(existingRuns[0]),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined)
|
||||
: undefined;
|
||||
|
||||
existingRuns.forEach((r) => {
|
||||
if (r.parentNode) r.parentNode.removeChild(r);
|
||||
});
|
||||
|
||||
sdtContent.appendChild(
|
||||
buildRun(doc, safeText, firstRPr),
|
||||
);
|
||||
}
|
||||
|
||||
function insertBlockContent(
|
||||
doc: XmlDocument,
|
||||
sdtContent: XmlElement,
|
||||
select: xpath.XPathSelect,
|
||||
text: string,
|
||||
allowMultiParagraph: boolean,
|
||||
): void {
|
||||
const allParagraphs = select(
|
||||
"./w:p",
|
||||
asXpathNode(sdtContent),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
if (allParagraphs.length === 0) {
|
||||
// ป้องกัน template ที่มีโครงสร้างผิดปกติ
|
||||
const p = doc.createElementNS(W_NS, "w:p");
|
||||
sdtContent.appendChild(p);
|
||||
allParagraphs.push(p);
|
||||
}
|
||||
|
||||
const firstP = allParagraphs[0];
|
||||
|
||||
const firstPPr = select(
|
||||
"./w:pPr",
|
||||
asXpathNode(firstP),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
const firstRun = select(
|
||||
"./w:r",
|
||||
asXpathNode(firstP),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
const firstRPr = firstRun
|
||||
? (select(
|
||||
"./w:rPr",
|
||||
asXpathNode(firstRun),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined)
|
||||
: undefined;
|
||||
|
||||
allParagraphs.forEach((p) => {
|
||||
if (p !== firstP && p.parentNode) {
|
||||
p.parentNode.removeChild(p);
|
||||
}
|
||||
});
|
||||
|
||||
const existingRuns = select(
|
||||
"./w:r",
|
||||
asXpathNode(firstP),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
existingRuns.forEach((r) => {
|
||||
if (r.parentNode) r.parentNode.removeChild(r);
|
||||
});
|
||||
|
||||
if (!allowMultiParagraph) {
|
||||
firstP.appendChild(buildRun(doc, text, firstRPr));
|
||||
return;
|
||||
}
|
||||
|
||||
text.split("\n").forEach((line, index) => {
|
||||
let targetP: XmlElement;
|
||||
|
||||
if (index === 0) {
|
||||
targetP = firstP;
|
||||
} else {
|
||||
targetP = doc.createElementNS(W_NS, "w:p");
|
||||
|
||||
if (firstPPr) {
|
||||
targetP.appendChild(firstPPr.cloneNode(true));
|
||||
}
|
||||
|
||||
sdtContent.appendChild(targetP);
|
||||
}
|
||||
|
||||
const run = doc.createElementNS(W_NS, "w:r");
|
||||
|
||||
if (firstRPr) {
|
||||
run.appendChild(firstRPr.cloneNode(true));
|
||||
}
|
||||
|
||||
buildRunChildren(doc, line).forEach((node) =>
|
||||
run.appendChild(node),
|
||||
);
|
||||
|
||||
targetP.appendChild(run);
|
||||
});
|
||||
}
|
||||
|
||||
function insertFallbackContent(
|
||||
sdtContent: XmlElement,
|
||||
select: xpath.XPathSelect,
|
||||
text: string,
|
||||
): void {
|
||||
const textNodes = select(
|
||||
".//w:t",
|
||||
asXpathNode(sdtContent),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
if (textNodes.length === 0) return;
|
||||
|
||||
textNodes[0].textContent = text;
|
||||
|
||||
for (let i = 1; i < textNodes.length; i++) {
|
||||
textNodes[i].textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateData = Record<string, unknown>;
|
||||
|
||||
function normalizeValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* นำ JSON data ไปแทนค่า Content Control ใน DOCX template
|
||||
* คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว
|
||||
*/
|
||||
export function templateContentControls(
|
||||
templateBuffer: Buffer,
|
||||
data: TemplateData,
|
||||
): Buffer {
|
||||
const zip = new PizzaZip(templateBuffer);
|
||||
const docXmlFile = zip.file("word/document.xml");
|
||||
|
||||
if (!docXmlFile) {
|
||||
throw new Error("Invalid docx file: word/document.xml not found.");
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
||||
const select = createSelector();
|
||||
|
||||
const sdtNodes = select(
|
||||
"//w:sdt",
|
||||
asXpathNode(doc),
|
||||
) as unknown as XmlElement[];
|
||||
|
||||
sdtNodes.forEach((sdt) => {
|
||||
const tagAttr = select(
|
||||
"./w:sdtPr/w:tag/@w:val",
|
||||
asXpathNode(sdt),
|
||||
true,
|
||||
) as unknown as XmlAttr | undefined;
|
||||
|
||||
const tagName = tagAttr?.value;
|
||||
|
||||
if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = normalizeValue(data[tagName]);
|
||||
|
||||
const sdtContent = select(
|
||||
"./w:sdtContent",
|
||||
asXpathNode(sdt),
|
||||
true,
|
||||
) as unknown as XmlElement | undefined;
|
||||
|
||||
if (!sdtContent) return;
|
||||
|
||||
const { type: controlType, multiLine } =
|
||||
detectSdtControlType(sdt, select);
|
||||
|
||||
const structure = detectSdtStructure(sdtContent, select);
|
||||
|
||||
if (controlType === "other") {
|
||||
insertFallbackContent(sdtContent, select, newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (structure === "inline") {
|
||||
const allowLineBreak =
|
||||
controlType === "plain" ? multiLine : true;
|
||||
|
||||
insertInlineContent(
|
||||
doc,
|
||||
sdtContent,
|
||||
select,
|
||||
newValue,
|
||||
allowLineBreak,
|
||||
);
|
||||
} else {
|
||||
insertBlockContent(
|
||||
doc,
|
||||
sdtContent,
|
||||
select,
|
||||
newValue,
|
||||
controlType === "rich",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
zip.file(
|
||||
"word/document.xml",
|
||||
serializer.serializeToString(doc),
|
||||
);
|
||||
|
||||
return zip.generate({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,94 +1,71 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
templateContentControls,
|
||||
type TemplateData,
|
||||
} from "../lib-tmpl";
|
||||
|
||||
import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
const template = formData.get("template");
|
||||
const dataFile = formData.get("data");
|
||||
const dataJson = formData.get("dataJson");
|
||||
const TEST_SECRET = String(formData.get("secret")||"")
|
||||
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
|
||||
|
||||
// -----------------------------
|
||||
// ตรวจสอบ Template
|
||||
// -----------------------------
|
||||
|
||||
if (!(template instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing DOCX file in field "template".',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
template.type !== DOCX_CONTENT_TYPE &&
|
||||
!template.name.toLowerCase().endsWith(".docx")
|
||||
if (template.type !== DOCX_CONTENT_TYPE &&
|
||||
!template.name.toLowerCase().endsWith(".docx") &&
|
||||
template.size > MAX_UPLOAD_BYTES
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "template" must be a .docx file.',
|
||||
error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (template.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "template" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// อ่าน JSON
|
||||
// -----------------------------
|
||||
|
||||
let data: TemplateData;
|
||||
|
||||
let templateData: DocumentTemplate;
|
||||
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
||||
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
||||
try {
|
||||
if (typeof dataJson === "string" && dataJson.length > 0) {
|
||||
// กรณีส่ง JSON ผ่าน form field
|
||||
data = JSON.parse(dataJson) as TemplateData;
|
||||
templateData = JSON.parse(dataJson) as DocumentTemplate;
|
||||
} else if (dataFile instanceof File) {
|
||||
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "data" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// กรณี upload data.json
|
||||
const text = await dataFile.text();
|
||||
|
||||
data = JSON.parse(text) as TemplateData;
|
||||
templateData = JSON.parse(text) as DocumentTemplate;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||
},
|
||||
|
|
@ -98,24 +75,20 @@ export async function POST(request: Request) {
|
|||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Invalid JSON data (${
|
||||
error instanceof Error ? error.message : "parse error"
|
||||
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
|
||||
}).`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// JSON ต้องเป็น object
|
||||
if (
|
||||
data === null ||
|
||||
typeof data !== "object" ||
|
||||
Array.isArray(data)
|
||||
templateData === null ||
|
||||
typeof templateData !== "object" ||
|
||||
Array.isArray(templateData)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "JSON data must be an object.",
|
||||
},
|
||||
{ status: 400 },
|
||||
|
|
@ -126,31 +99,21 @@ export async function POST(request: Request) {
|
|||
// Template File → Buffer
|
||||
// -----------------------------
|
||||
|
||||
const templateBuffer = Buffer.from(
|
||||
let templateBuffer: Buffer = Buffer.from(
|
||||
await template.arrayBuffer(),
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// ใช้ lib-tmpl.ts เดิม
|
||||
// -----------------------------
|
||||
|
||||
const output = templateContentControls(
|
||||
templateBuffer,
|
||||
data,
|
||||
);
|
||||
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC);
|
||||
|
||||
// -----------------------------
|
||||
// ส่ง DOCX กลับ
|
||||
// -----------------------------
|
||||
|
||||
if(templateData?.info?.ticket_data)
|
||||
return new NextResponse(output as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": DOCX_CONTENT_TYPE,
|
||||
|
||||
"Content-Disposition":
|
||||
'attachment; filename="generated-document.docx"',
|
||||
|
||||
"Content-Length": String(output.length),
|
||||
},
|
||||
});
|
||||
|
|
@ -159,7 +122,7 @@ export async function POST(request: Request) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
|
@ -169,3 +132,4 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||
"nottype": "(ต. ๑)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"blackyear": "๑๐/๒๕๖๙",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
"redyear": "",
|
||||
"court": "ศาลปกครองสุพรรณบุรี",
|
||||
"curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
|
||||
"accuser": "นายธัญวิสิษฐ์ ศรแก้ว",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"nottitle": "คำสั่งถึง",
|
||||
"commto": "เจ้าคุณมด",
|
||||
"groupname": "ผู้ถูกฟ้องคดีที่ ๑",
|
||||
"detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้",
|
||||
"pname": "นางนพเก้า เถื่อนชัย",
|
||||
"courtname": "ศาลปกครองสุพรรณบุรี",
|
||||
"courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||
"courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||
"courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||
"courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”",
|
||||
"sendto": "เจ้าคุณมด",
|
||||
"addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -",
|
||||
"remarkhead": "",
|
||||
"remark": "",
|
||||
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
|
||||
}
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties
|
||||
// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md
|
||||
//
|
||||
// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
|
||||
// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด)
|
||||
//
|
||||
// สิ่งที่แก้ในไฟล์ docx:
|
||||
// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1"
|
||||
// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย
|
||||
// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้)
|
||||
// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย
|
||||
// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone"
|
||||
// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค)
|
||||
// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้
|
||||
// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr
|
||||
// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml
|
||||
// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml
|
||||
import PizzaZip from "pizzip";
|
||||
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
||||
import xpath from "xpath";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx");
|
||||
const outPath = process.argv[3] ?? inPath;
|
||||
|
||||
// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร)
|
||||
const CUSTOM_PROPS = {
|
||||
token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo",
|
||||
"primary-key": "DOC-2026-000123",
|
||||
owner: "u1001",
|
||||
};
|
||||
|
||||
const W_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";
|
||||
const REL_CUSTOM =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
|
||||
const CT_CUSTOM =
|
||||
"application/vnd.openxmlformats-officedocument.custom-properties+xml";
|
||||
const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
|
||||
|
||||
const parser = new DOMParser();
|
||||
const serializer = new XMLSerializer();
|
||||
|
||||
const parseXml = (text) => parser.parseFromString(text, "text/xml");
|
||||
|
||||
// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี
|
||||
// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid)
|
||||
const serialize = (doc) => {
|
||||
const text = serializer.serializeToString(doc);
|
||||
return text.startsWith("<?xml")
|
||||
? text
|
||||
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + text;
|
||||
};
|
||||
|
||||
// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone")
|
||||
// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ
|
||||
function unlockContentControls(zip) {
|
||||
const select = xpath.useNamespaces({ w: W_NS });
|
||||
const asNode = (n) => n;
|
||||
const parts = Object.keys(zip.files).filter((n) =>
|
||||
/^word\/(document|header\d*|footer\d*)\.xml$/.test(n),
|
||||
);
|
||||
|
||||
// id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part)
|
||||
let nextId = 999;
|
||||
for (const part of parts) {
|
||||
const ids = select(
|
||||
"//w:permStart/@w:id | //w:permEnd/@w:id",
|
||||
asNode(parseXml(zip.file(part).asText())),
|
||||
);
|
||||
for (const id of ids) {
|
||||
const n = Number.parseInt(id.value, 10);
|
||||
if (Number.isFinite(n) && n >= nextId) nextId = n;
|
||||
}
|
||||
}
|
||||
|
||||
let wrapped = 0;
|
||||
let locked = 0;
|
||||
for (const part of parts) {
|
||||
const doc = parseXml(zip.file(part).asText());
|
||||
|
||||
// เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template
|
||||
// และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ)
|
||||
const sdts = select(
|
||||
"//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]",
|
||||
asNode(doc),
|
||||
);
|
||||
if (sdts.length === 0) continue;
|
||||
|
||||
for (const sdt of sdts) {
|
||||
// เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission
|
||||
const sdtPr = select("./w:sdtPr", sdt)[0];
|
||||
const hasLock = select("./w:lock", sdtPr).length > 0;
|
||||
if (!hasLock) {
|
||||
const lock = doc.createElementNS(W_NS, "w:lock");
|
||||
lock.setAttribute("w:val", "sdtLocked");
|
||||
// w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr
|
||||
const idEl = select("./w:id", sdtPr)[0];
|
||||
if (idEl && idEl.nextSibling) {
|
||||
sdtPr.insertBefore(lock, idEl.nextSibling);
|
||||
} else {
|
||||
sdtPr.appendChild(lock);
|
||||
}
|
||||
locked++;
|
||||
}
|
||||
|
||||
const start = doc.createElementNS(W_NS, "w:permStart");
|
||||
start.setAttribute("w:id", String(nextId));
|
||||
start.setAttribute("w:edGrp", "everyone");
|
||||
const end = doc.createElementNS(W_NS, "w:permEnd");
|
||||
end.setAttribute("w:id", String(nextId));
|
||||
nextId++;
|
||||
|
||||
sdt.parentNode.insertBefore(start, sdt);
|
||||
sdt.parentNode.insertBefore(end, sdt.nextSibling);
|
||||
wrapped++;
|
||||
}
|
||||
|
||||
zip.file(part, serialize(doc));
|
||||
}
|
||||
return { wrapped, locked };
|
||||
}
|
||||
|
||||
function enableReadOnlyProtection(zip) {
|
||||
const file = zip.file("word/settings.xml");
|
||||
if (!file) throw new Error("word/settings.xml not found");
|
||||
|
||||
const doc = parseXml(file.asText());
|
||||
const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection");
|
||||
const prot = prots.length > 0 ? prots[0] : null;
|
||||
|
||||
if (prot) {
|
||||
prot.setAttribute("w:edit", "readOnly");
|
||||
prot.setAttribute("w:enforcement", "1");
|
||||
} else {
|
||||
// T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings
|
||||
const el = doc.createElementNS(W_NS, "w:documentProtection");
|
||||
el.setAttribute("w:edit", "readOnly");
|
||||
el.setAttribute("w:enforcement", "1");
|
||||
doc.documentElement.insertBefore(el, doc.documentElement.firstChild);
|
||||
}
|
||||
|
||||
zip.file("word/settings.xml", serialize(doc));
|
||||
}
|
||||
|
||||
function setCustomProperties(zip, props) {
|
||||
// --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม ---
|
||||
const existing = zip.file("docProps/custom.xml");
|
||||
let doc;
|
||||
if (existing) {
|
||||
doc = parseXml(existing.asText());
|
||||
// ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่)
|
||||
const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property"));
|
||||
for (const p of all) {
|
||||
if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) {
|
||||
p.parentNode.removeChild(p);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
doc = parseXml(
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
// pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1
|
||||
const usedPids = Array.from(
|
||||
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
|
||||
).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1);
|
||||
let nextPid = Math.max(1, ...usedPids) + 1;
|
||||
|
||||
for (const [name, value] of Object.entries(props)) {
|
||||
const prop = doc.createElementNS(CUSTOM_NS, "property");
|
||||
prop.setAttribute("fmtid", FMTID);
|
||||
prop.setAttribute("pid", String(nextPid++));
|
||||
prop.setAttribute("name", name);
|
||||
const val = doc.createElementNS(VT_NS, "vt:lpwstr");
|
||||
val.textContent = value;
|
||||
prop.appendChild(val);
|
||||
doc.documentElement.appendChild(prop);
|
||||
}
|
||||
zip.file("docProps/custom.xml", serialize(doc));
|
||||
|
||||
// --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) ---
|
||||
const ct = parseXml(zip.file("[Content_Types].xml").asText());
|
||||
const hasCt = Array.from(ct.getElementsByTagName("Override")).some(
|
||||
(o) => o.getAttribute("PartName") === "/docProps/custom.xml",
|
||||
);
|
||||
if (!hasCt) {
|
||||
const el = ct.createElement("Override");
|
||||
el.setAttribute("PartName", "/docProps/custom.xml");
|
||||
el.setAttribute("ContentType", CT_CUSTOM);
|
||||
ct.documentElement.appendChild(el);
|
||||
zip.file("[Content_Types].xml", serialize(ct));
|
||||
}
|
||||
|
||||
// --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) ---
|
||||
const rels = parseXml(zip.file("_rels/.rels").asText());
|
||||
const relNodes = Array.from(rels.getElementsByTagName("Relationship"));
|
||||
if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) {
|
||||
const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id")));
|
||||
let n = 1;
|
||||
while (usedIds.has(`rId${n}`)) n++;
|
||||
const el = rels.createElement("Relationship");
|
||||
el.setAttribute("Id", `rId${n}`);
|
||||
el.setAttribute("Type", REL_CUSTOM);
|
||||
el.setAttribute("Target", "docProps/custom.xml");
|
||||
rels.documentElement.appendChild(el);
|
||||
zip.file("_rels/.rels", serialize(rels));
|
||||
}
|
||||
}
|
||||
|
||||
// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์
|
||||
function verify(path) {
|
||||
const zip = new PizzaZip(readFileSync(path));
|
||||
|
||||
const settings = zip.file("word/settings.xml")?.asText() ?? "";
|
||||
const prot = settings.match(/<w:documentProtection[^>]*\/>/)?.[0];
|
||||
const enforced =
|
||||
prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"');
|
||||
|
||||
const customXml = zip.file("docProps/custom.xml")?.asText();
|
||||
const propsBack = {};
|
||||
if (customXml) {
|
||||
const doc = parseXml(customXml);
|
||||
for (const p of Array.from(
|
||||
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
|
||||
)) {
|
||||
propsBack[p.getAttribute("name")] = p.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
const rels = zip.file("_rels/.rels")?.asText() ?? "";
|
||||
const ct = zip.file("[Content_Types].xml")?.asText() ?? "";
|
||||
|
||||
const docXml = zip.file("word/document.xml")?.asText() ?? "";
|
||||
const sdtPrs = docXml.match(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
|
||||
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
|
||||
const rich = sdtPrs.length - plain;
|
||||
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
|
||||
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
|
||||
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
|
||||
const missingLock = sdtPrs.filter(
|
||||
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
|
||||
).length;
|
||||
|
||||
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
|
||||
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
|
||||
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
|
||||
console.log(
|
||||
"custom properties :",
|
||||
Object.entries(propsBack)
|
||||
.map(([k, v]) => `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`)
|
||||
.join(", ") || "(ว่าง)",
|
||||
);
|
||||
console.log(
|
||||
`relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`,
|
||||
);
|
||||
console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`);
|
||||
console.log(
|
||||
`permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` +
|
||||
(permStarts === tagged && permEnds === tagged ? "✓" : "✗"),
|
||||
);
|
||||
console.log(
|
||||
`CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`,
|
||||
);
|
||||
|
||||
return (
|
||||
enforced &&
|
||||
!!customXml &&
|
||||
permStarts === tagged &&
|
||||
permEnds === tagged &&
|
||||
missingLock === 0
|
||||
);
|
||||
}
|
||||
|
||||
const zip = new PizzaZip(readFileSync(inPath));
|
||||
enableReadOnlyProtection(zip);
|
||||
const { wrapped, locked } = unlockContentControls(zip);
|
||||
setCustomProperties(zip, CUSTOM_PROPS);
|
||||
|
||||
if (locked > 0) {
|
||||
console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`);
|
||||
}
|
||||
console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`);
|
||||
|
||||
const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" });
|
||||
writeFileSync(outPath, out);
|
||||
console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`);
|
||||
|
||||
const ok = verify(outPath);
|
||||
process.exit(ok ? 0 : 1);
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# Docx Template
|
||||
lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word
|
||||
## วิธีการใช้ API
|
||||
```sh
|
||||
## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login)
|
||||
http://localhost:3000/docedit/api/swagger
|
||||
|
||||
## template/test ไว้ทดสอบเท่านั้น
|
||||
curl.exe http://localhost:3000/docedit/api/template/test
|
||||
|
||||
## extract ค่าออกมาจาก content control
|
||||
curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx"
|
||||
|
||||
## render เอกสาร (docx) = template(docx)+data(json)
|
||||
### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน
|
||||
curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx
|
||||
### data เอาจาก string
|
||||
curl -X POST http://localhost:3000/docedit/api/template/render \
|
||||
-F "template=@template.docx" \
|
||||
-F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \
|
||||
-o generated-document.docx
|
||||
|
||||
```
|
||||
data(json) ตอนนี้อยู่ในรูปแบบ
|
||||
```json
|
||||
{
|
||||
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||
"nottype": "(ต. ๑)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"blackyear": "๑๐/๒๕๖๙",
|
||||
...
|
||||
}
|
||||
```
|
||||
data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration
|
||||
หลักผ่าน API
|
||||
- ส่วนของ cc จะไปกรอกใน Content Control
|
||||
- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร
|
||||
```json
|
||||
{
|
||||
"uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร",
|
||||
"uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล",
|
||||
"fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน",
|
||||
"ott":"อายุของ one time token แบบระยะสั้น",
|
||||
"expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)",
|
||||
cc:{
|
||||
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||
"nottype": "(ต. ๑)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"blackyear": "๑๐/๒๕๖๙",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
ที่ต้องทำเพิ่ม
|
||||
- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต
|
||||
- Backend จัดการ version
|
||||
- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน
|
||||
|
||||
# ทดลอง Restrict Edit + Custom Properties (2026-10-07)
|
||||
|
||||
สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง
|
||||
|
||||
```sh
|
||||
node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
|
||||
# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS)
|
||||
```
|
||||
|
||||
## Restrict Edit (เปิดผ่านโค้ด)
|
||||
|
||||
- อยู่ที่ `word/settings.xml` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
|
||||
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
|
||||
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
|
||||
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar)
|
||||
- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย
|
||||
- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ)
|
||||
|
||||
## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner)
|
||||
|
||||
ทางเลือกที่ศึกษา:
|
||||
1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+)
|
||||
- ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย:
|
||||
1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr`
|
||||
2. `[Content_Types].xml` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>`
|
||||
3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml`
|
||||
2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token
|
||||
3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก
|
||||
|
||||
gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
|
||||
|
||||
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
|
||||
|
||||
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
|
||||
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt
|
||||
- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง
|
||||
|
||||
## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07)
|
||||
|
||||
- ค่า OOXML คือ `<w:jc w:val="thaiDistribute"/>` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์
|
||||
- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ)
|
||||
- วิธีจัดเองใน Word:
|
||||
- รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed
|
||||
- ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed
|
||||
- จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด
|
||||
- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render
|
||||
- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`)
|
||||
|
||||
## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์)
|
||||
|
||||
- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓
|
||||
- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓
|
||||
- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓
|
||||
- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓
|
||||
- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓
|
||||
- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓
|
||||
|
||||
## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1)
|
||||
|
||||
- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้
|
||||
- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2)
|
||||
- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password
|
||||
|
||||
## เหลือตรวจใน Word จริง (รอบ 2)
|
||||
|
||||
- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5)
|
||||
- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้)
|
||||
- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ
|
||||
- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline)
|
||||
Binary file not shown.
|
|
@ -1,43 +0,0 @@
|
|||
@baseUrl = http://localhost:3000/docedit/api
|
||||
|
||||
### 1. Extract Template
|
||||
POST {{baseUrl}}/template/extract
|
||||
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||
|
||||
--WebAppBoundary
|
||||
Content-Disposition: form-data; name="file"; filename="output-T3.docx"
|
||||
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
|
||||
< ./output-T3.docx
|
||||
--WebAppBoundary--
|
||||
|
||||
### 2. Render Template with JSON File
|
||||
POST {{baseUrl}}/template/render
|
||||
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||
|
||||
--WebAppBoundary
|
||||
Content-Disposition: form-data; name="template"; filename="T3.docx"
|
||||
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
|
||||
< ./T3.docx
|
||||
--WebAppBoundary
|
||||
Content-Disposition: form-data; name="data"; filename="T2.json"
|
||||
Content-Type: application/json
|
||||
|
||||
< ./T2.json
|
||||
--WebAppBoundary--
|
||||
|
||||
### 3. Render Template with Raw JSON String
|
||||
POST {{baseUrl}}/template/render
|
||||
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||
|
||||
--WebAppBoundary
|
||||
Content-Disposition: form-data; name="template"; filename="template.docx"
|
||||
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
|
||||
< ./T3.docx
|
||||
--WebAppBoundary
|
||||
Content-Disposition: form-data; name="dataJson"
|
||||
|
||||
{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"}
|
||||
--WebAppBoundary--
|
||||
33
app/page.tsx
Normal file
33
app/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
|
||||
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
|
||||
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
|
||||
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
|
||||
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
|
||||
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
|
||||
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
|
||||
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
|
||||
|
||||
export default function RootPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const entries = Object.entries(searchParams);
|
||||
const isEcourtLink = entries.some(([key]) =>
|
||||
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
|
||||
);
|
||||
if (!isEcourtLink) {
|
||||
redirect("/wordaddin");
|
||||
}
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of entries) {
|
||||
if (typeof value === "string") {
|
||||
qs.set(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) qs.append(key, v);
|
||||
}
|
||||
}
|
||||
redirect(`/note?${qs.toString()}`);
|
||||
}
|
||||
100
app/tmpl/page.tsx
Normal file
100
app/tmpl/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// app/page.tsx (ระบบ B)
|
||||
'use client';
|
||||
import type { ChangeEvent } from 'react';
|
||||
// import { createDownloadFormAction } from './actions';
|
||||
import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown';
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
const templateData: DropdownData = {
|
||||
T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'],
|
||||
T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'],
|
||||
};
|
||||
const handleTmplSelect = (template: string, data: string) => {
|
||||
//console.log('Location Selected:', { template, data });
|
||||
};
|
||||
const handleInput = (e: ChangeEvent<HTMLInputElement>)=>{
|
||||
// console.log(e.target)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
|
||||
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
|
||||
<div>
|
||||
ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET)
|
||||
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||
ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<form action="../api/template/download-docx" method="POST">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
|
||||
<DependentDropdown data={templateData} name1='tid' name2='fid'
|
||||
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
|
||||
<div className="mb-2"></div>
|
||||
<div>
|
||||
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
|
||||
</div>
|
||||
<div>
|
||||
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0070f3',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
ดาวน์โหลดเอกสาร MS Word
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">A Link Get</h2>
|
||||
<a href="../api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</a><br/>
|
||||
<a href="../api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</a><br/>
|
||||
<a href="../api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</a><br/>
|
||||
|
||||
<a href="../api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</a><br/>
|
||||
<a href="../api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
|
||||
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>,
|
||||
<a href="../tmpl/T12_X.docx">T12_X.docx</a><br/>
|
||||
Data: <a href="../tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>,
|
||||
<a href="../tmpl/T_no_warning.json">T_no_warning.json</a>,
|
||||
<a href="../tmpl/TS_P2_IN.json">TS_P2_IN.json</a><br/>
|
||||
<a href="../tmpl/T12_b2_IN.json">T12_b2_IN.json</a>,
|
||||
<a href="../tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
|
||||
<div className="text-xl font-bold text-center">
|
||||
หน้านี้(เลือกไฟล์)<br/>
|
||||
↓ <br/>
|
||||
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
|
||||
↓ <br/>
|
||||
generate(เปิดเทมเพลต,สร้าง docx)<br/>
|
||||
↓ <br/>
|
||||
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
|
||||
↓ <br/>
|
||||
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
|
||||
↓ <br/>
|
||||
Versioning(คุมเวอร์ชั่น)<br/>
|
||||
↓ <br/>
|
||||
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
app/wordaddin/error.tsx
Normal file
37
app/wordaddin/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า
|
||||
// "Application error: a client-side exception has occurred" ของ Next
|
||||
// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร
|
||||
// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้)
|
||||
export default function WordAddinError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col gap-3 p-4">
|
||||
<h2 className="font-display text-base font-semibold text-ink">task pane ทำงานผิดพลาด</h2>
|
||||
<p className="text-sm text-ink-soft">
|
||||
เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่
|
||||
</p>
|
||||
<pre
|
||||
className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-canvas p-3 text-xs text-error-ink"
|
||||
aria-label="รายละเอียดข้อผิดพลาด"
|
||||
>
|
||||
{error.message}
|
||||
{error.digest ? `\n[digest ${error.digest}]` : ""}
|
||||
{error.stack ? `\n\n${error.stack}` : ""}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring w-fit rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-primary hover:text-primary"
|
||||
onClick={reset}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/wordaddin/layout.tsx
Normal file
10
app/wordaddin/layout.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
|
||||
export const metadata: Metadata = {
|
||||
title: "e-Court Documents Versioning",
|
||||
};
|
||||
|
||||
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
33
app/wordaddin/manifest.xml/route.ts
Normal file
33
app/wordaddin/manifest.xml/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { requestOrigin } from "@/utils/request-origin";
|
||||
|
||||
// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload
|
||||
//
|
||||
// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม
|
||||
// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้
|
||||
// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route
|
||||
// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว
|
||||
// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template
|
||||
// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น)
|
||||
//
|
||||
// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker
|
||||
// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image)
|
||||
|
||||
/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */
|
||||
const DEV_ORIGIN = "https://localhost:3000";
|
||||
|
||||
const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml");
|
||||
|
||||
export function GET(req: Request): Response {
|
||||
const template = fs.readFileSync(TEMPLATE, "utf8");
|
||||
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
|
||||
return new Response(manifest, {
|
||||
headers: {
|
||||
"content-type": "application/xml; charset=utf-8",
|
||||
// origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
7
app/wordaddin/page.tsx
Normal file
7
app/wordaddin/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
|
||||
|
||||
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
|
||||
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
|
||||
export default function WordAddinPage() {
|
||||
return <WordAddinGate />;
|
||||
}
|
||||
91
components/utils/DependentDropdown.tsx
Normal file
91
components/utils/DependentDropdown.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use client';
|
||||
|
||||
import { useState, ChangeEvent, useEffect } from 'react';
|
||||
|
||||
export interface DropdownData {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
interface DependentDropdownProps {
|
||||
data: DropdownData;
|
||||
name1: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||
name2: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||
label1?: string;
|
||||
label2?: string;
|
||||
placeholder1?: string;
|
||||
placeholder2?: string;
|
||||
onSelectChange?: (mainValue: string, subValue: string) => void;
|
||||
}
|
||||
|
||||
export default function DependentDropdown({
|
||||
data,
|
||||
name1,
|
||||
name2,
|
||||
label1 = 'เลือกตัวเลือกหลัก:',
|
||||
label2 = 'เลือกตัวเลือกย่อย:',
|
||||
placeholder1 = '-- กรุณาเลือก --',
|
||||
placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --',
|
||||
onSelectChange,
|
||||
}: DependentDropdownProps) {
|
||||
const [mainSelection, setMainSelection] = useState<string>('');
|
||||
const [subSelection, setSubSelection] = useState<string>('');
|
||||
|
||||
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setMainSelection(value);
|
||||
setSubSelection('');
|
||||
};
|
||||
|
||||
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setSubSelection(e.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectChange) {
|
||||
onSelectChange(mainSelection, subSelection);
|
||||
}
|
||||
}, [mainSelection, subSelection, onSelectChange]);
|
||||
|
||||
const subOptions = mainSelection ? data[mainSelection] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{/* Dropdown ที่ 1 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label1}</label>
|
||||
<select
|
||||
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={mainSelection}
|
||||
onChange={handleMainChange}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
|
||||
>
|
||||
<option value="">{placeholder1}</option>
|
||||
{Object.keys(data).map((key) => (
|
||||
<option key={key} value={key}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dropdown ที่ 2 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label2}</label>
|
||||
<select
|
||||
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={subSelection}
|
||||
onChange={handleSubChange}
|
||||
disabled={!mainSelection}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
|
||||
>
|
||||
<option value="">
|
||||
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
|
||||
</option>
|
||||
{subOptions.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
|
||||
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
|
||||
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
|
||||
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
|
||||
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
|
||||
* (pattern overlay เดียวกับ AnnotationSidebar)
|
||||
*/
|
||||
|
||||
const DEFAULT_TITLE = "สร้าง version ใหม่?";
|
||||
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
|
||||
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function ConfirmVersionDialog({
|
||||
open,
|
||||
busy,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onConfirmClose,
|
||||
onDismiss,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
|
||||
busy: boolean;
|
||||
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
|
||||
onConfirm: (message: string) => void;
|
||||
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
|
||||
onConfirmClose?: (message: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
|
||||
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMessage("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
|
||||
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy) onDismiss();
|
||||
if (event.key === "Tab") {
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
const first = focusables?.item(0);
|
||||
const last = focusables?.item((focusables?.length ?? 0) - 1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, busy, onDismiss]);
|
||||
|
||||
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||
onClick={() => !busy && onDismiss()}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ?? DEFAULT_TITLE}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
|
||||
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
|
||||
คำอธิบาย version (ไม่บังคับ)
|
||||
</label>
|
||||
<textarea
|
||||
id="wordaddin-version-message"
|
||||
className="genesis-input mt-1 block w-full resize-y"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
|
||||
maxLength={2048}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
aria-label="คำอธิบาย version"
|
||||
/>
|
||||
{busy ? (
|
||||
<div className="mt-3">
|
||||
<Spinner label="กำลังดำเนินการ…" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
|
||||
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirm(message)}
|
||||
>
|
||||
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
{onConfirmClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full`}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirmClose(message)}
|
||||
>
|
||||
บันทึกเวอร์ชันและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmVersionDialog;
|
||||
45
components/wordaddin/MessageBar.tsx
Normal file
45
components/wordaddin/MessageBar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
|
||||
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
|
||||
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
|
||||
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
|
||||
* แถบกว้างเท่ากันทั้งคอลัมน์
|
||||
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
|
||||
*/
|
||||
|
||||
const ICONS = {
|
||||
error: CircleAlert,
|
||||
warning: TriangleAlert,
|
||||
success: CircleCheck,
|
||||
} as const;
|
||||
|
||||
export function MessageBar({
|
||||
intent,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
intent: "error" | "warning" | "success";
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const Icon = ICONS[intent];
|
||||
const tone =
|
||||
intent === "error"
|
||||
? "border-error/30 bg-error/10 text-error-ink"
|
||||
: intent === "warning"
|
||||
? "border-warning/30 bg-warning/15 text-warning-ink"
|
||||
: "border-success/30 bg-success/10 text-success-ink";
|
||||
return (
|
||||
<div
|
||||
role={intent === "error" ? "alert" : "status"}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1 break-words">{children}</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
components/wordaddin/SaveControls.tsx
Normal file
65
components/wordaddin/SaveControls.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { CircleCheck, Save, X } from "lucide-react";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
|
||||
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||
*/
|
||||
export function SaveControls({
|
||||
disabled,
|
||||
busy,
|
||||
status,
|
||||
error,
|
||||
closeSupported,
|
||||
onSave,
|
||||
onSaveAndClose,
|
||||
}: {
|
||||
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
status: "saving" | "saved" | null;
|
||||
error: string | null;
|
||||
closeSupported: boolean;
|
||||
onSave: () => void;
|
||||
onSaveAndClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
<Save className="size-4" aria-hidden />
|
||||
บันทึก
|
||||
</button>
|
||||
{closeSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSaveAndClose}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
บันทึกและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
|
||||
{!busy && status === "saved" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
|
||||
<CircleCheck className="size-4 text-success-ink" aria-hidden />
|
||||
บันทึกแล้ว
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveControls;
|
||||
13
components/wordaddin/Spinner.tsx
Normal file
13
components/wordaddin/Spinner.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
|
||||
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
|
||||
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
{label ? <span>{label}</span> : null}
|
||||
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
616
components/wordaddin/TaskpaneApp.tsx
Normal file
616
components/wordaddin/TaskpaneApp.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
/* global Office */
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileUp, GitCommitHorizontal } from "lucide-react";
|
||||
|
||||
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
|
||||
import * as authService from "@/wordaddin/services/authService";
|
||||
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
|
||||
import * as documentService from "@/wordaddin/services/documentService";
|
||||
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||
import * as metadataService from "@/wordaddin/services/metadataService";
|
||||
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
|
||||
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
|
||||
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||
|
||||
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import SaveControls from "./SaveControls";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TemplateToolsPanel from "./TemplateToolsPanel";
|
||||
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||
|
||||
/**
|
||||
* Task pane หลัก — state machine:
|
||||
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
|
||||
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
|
||||
* → (ticket ผ่าน + tracked document) tracking
|
||||
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
|
||||
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
|
||||
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
|
||||
* ตาม fid — PLAN-ADDIN-AUTH.md)
|
||||
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
|
||||
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
|
||||
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
||||
*
|
||||
* กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||
*
|
||||
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
|
||||
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
|
||||
*/
|
||||
|
||||
type Phase = "checking" | "no-access" | "untracked" | "tracking";
|
||||
|
||||
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
|
||||
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
|
||||
type Tab = "versioning" | "template";
|
||||
|
||||
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
|
||||
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
|
||||
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
|
||||
const tabButtonClass = (active: boolean) =>
|
||||
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
|
||||
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
|
||||
}`;
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
|
||||
function demoVersions(now: number): VersionRecord[] {
|
||||
return [
|
||||
{
|
||||
versionId: "demo-v3",
|
||||
versionNumber: 3,
|
||||
createdAt: new Date(now - 3_600_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: "ปรับคำร้องตามคำแนะนำอัยการ",
|
||||
hasPdf: true,
|
||||
fields: null,
|
||||
},
|
||||
{
|
||||
versionId: "demo-v2",
|
||||
versionNumber: 2,
|
||||
createdAt: new Date(now - 86_400_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: null,
|
||||
hasPdf: true,
|
||||
fields: null,
|
||||
},
|
||||
{
|
||||
versionId: "demo-v1",
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(now - 172_800_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: "สร้างเอกสารครั้งแรก",
|
||||
hasPdf: false,
|
||||
fields: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
|
||||
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("versioning");
|
||||
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
|
||||
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
|
||||
const [templateBusy, setTemplateBusy] = useState(false);
|
||||
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
|
||||
const [bootError, setBootError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
|
||||
|
||||
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
|
||||
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
|
||||
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
|
||||
const [flowError, setFlowError] = useState<string | null>(null);
|
||||
const [flowWarning, setFlowWarning] = useState<string | null>(null);
|
||||
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
|
||||
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
|
||||
|
||||
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
|
||||
|
||||
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
|
||||
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
|
||||
const [closeSupported] = useState(() => documentService.canCloseDocument());
|
||||
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
|
||||
const [saveBusy, setSaveBusy] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
|
||||
const [hasUncommitted, setHasUncommitted] = useState(false);
|
||||
|
||||
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
|
||||
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
|
||||
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
||||
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
setPhase("no-access");
|
||||
setActiveTab("versioning");
|
||||
setDocumentId(null);
|
||||
setDocumentTemplateId(null);
|
||||
setVersions([]);
|
||||
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const refreshHistory = useCallback(
|
||||
async (id: string) => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
setVersions(await getVersions(id));
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
[handleAuthRequired],
|
||||
);
|
||||
|
||||
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
|
||||
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
||||
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
|
||||
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
|
||||
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
||||
const detect = useCallback(async () => {
|
||||
setPhase("checking");
|
||||
setBootError(null);
|
||||
try {
|
||||
let properties: metadataService.TrackedDocumentProperties | null = null;
|
||||
try {
|
||||
properties = await metadataService.readTrackedProperties();
|
||||
} catch {
|
||||
properties = null;
|
||||
}
|
||||
if (!properties) {
|
||||
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
const signIn = await authService.signInWithTicket({
|
||||
ticketData: properties.ticketData,
|
||||
ticketHmac: properties.ticketHmac,
|
||||
});
|
||||
if (!signIn.ok) {
|
||||
setNoAccessNotice(signIn.message);
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
setUsername(authService.getUsername() ?? null);
|
||||
|
||||
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||
const id = tracked ? properties.documentTrackingId : undefined;
|
||||
if (!id) {
|
||||
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
|
||||
setDocumentTemplateId(properties.templateId ?? null);
|
||||
setPhase("untracked");
|
||||
return;
|
||||
}
|
||||
setDocumentId(id);
|
||||
setDocumentTemplateId(null);
|
||||
setPhase("tracking");
|
||||
await refreshHistory(id);
|
||||
} catch (error) {
|
||||
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
}, [refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
|
||||
void detect();
|
||||
}, [demo, detect]);
|
||||
|
||||
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
|
||||
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
|
||||
useEffect(() => {
|
||||
autoSaveService.onError = (error) =>
|
||||
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
|
||||
autoSaveService.onSaved = () => setAutoSaveError(null);
|
||||
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
|
||||
if (!demo) {
|
||||
autoSaveService.start();
|
||||
}
|
||||
return () => {
|
||||
autoSaveService.stop();
|
||||
autoSaveService.onError = undefined;
|
||||
autoSaveService.onSaved = undefined;
|
||||
autoSaveService.onStateChange = undefined;
|
||||
};
|
||||
}, [demo]);
|
||||
|
||||
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
|
||||
const handleSaveOnly = async () => {
|
||||
setSaveBusy(true);
|
||||
setSaveStatus("saving");
|
||||
setSaveError(null);
|
||||
setAutoSaveError(null);
|
||||
try {
|
||||
await autoSaveService.save();
|
||||
setSaveStatus("saved");
|
||||
} catch (error) {
|
||||
setSaveStatus(null);
|
||||
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
|
||||
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
|
||||
const handleSaveAndClose = async () => {
|
||||
setSaveBusy(true);
|
||||
setSaveStatus("saving");
|
||||
setSaveError(null);
|
||||
setAutoSaveError(null);
|
||||
try {
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
await autoSaveService.forceSave();
|
||||
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveStatus(null);
|
||||
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
|
||||
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
|
||||
const handleStartVersioning = async (message: string) => {
|
||||
setStarting(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
setFlowWarning(null);
|
||||
try {
|
||||
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
|
||||
setDocumentId(result.documentId);
|
||||
setDocumentTemplateId(null);
|
||||
setPhase("tracking");
|
||||
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||
if (!result.hasPdf)
|
||||
setFlowWarning(
|
||||
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||
);
|
||||
await refreshHistory(result.documentId);
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
|
||||
* message คือ commit message จาก dialog (FR-13)
|
||||
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
|
||||
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
setFlowWarning(null);
|
||||
try {
|
||||
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
|
||||
if (closeAfter) {
|
||||
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
|
||||
return;
|
||||
}
|
||||
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||
if (!result.hasPdf)
|
||||
setFlowWarning(
|
||||
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||
);
|
||||
await refreshHistory(documentId);
|
||||
} catch (error) {
|
||||
if (error instanceof VersionSavedButCloseFailedError) {
|
||||
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
|
||||
setFlowWarning(error.message);
|
||||
await refreshHistory(documentId);
|
||||
return;
|
||||
}
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
|
||||
*/
|
||||
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
|
||||
setDownloading(version.versionId);
|
||||
setFlowError(null);
|
||||
try {
|
||||
const link = await getDownloadLink(documentId, version.versionId, format);
|
||||
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||
Office.context.ui.openBrowserWindow(link.url);
|
||||
setFlowSuccess(
|
||||
format === "pdf"
|
||||
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
|
||||
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
|
||||
{phase !== "no-access" && phase !== "checking" && username ? (
|
||||
/* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
|
||||
จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
|
||||
<span
|
||||
className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
|
||||
title={username}
|
||||
>
|
||||
{username}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
|
||||
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
|
||||
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
|
||||
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
|
||||
<MessageBar intent="warning">{autoSaveError}</MessageBar>
|
||||
) : null}
|
||||
|
||||
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ)
|
||||
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
|
||||
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
|
||||
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
|
||||
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
|
||||
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
|
||||
{(phase === "untracked" || phase === "tracking") && (
|
||||
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "versioning"}
|
||||
className={tabButtonClass(activeTab === "versioning")}
|
||||
disabled={saving || starting || saveBusy || templateBusy}
|
||||
onClick={() => setActiveTab("versioning")}
|
||||
>
|
||||
Versioning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "template"}
|
||||
className={tabButtonClass(activeTab === "template")}
|
||||
disabled={saving || starting || saveBusy || templateBusy}
|
||||
onClick={() => setActiveTab("template")}
|
||||
>
|
||||
เครื่องมือ Template
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "checking" ? (
|
||||
<div className="flex flex-col items-center gap-4 pt-10">
|
||||
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||
{bootError ? (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => void detect()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{bootError}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "no-access" ? (
|
||||
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "untracked" && activeTab === "versioning" ? (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||
</p>
|
||||
{documentTemplateId ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||
และ sync ค่า fields ให้อัตโนมัติ
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||
disabled={starting}
|
||||
onClick={() => setStartConfirmOpen(true)}
|
||||
>
|
||||
<FileUp className="size-4" aria-hidden />
|
||||
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||
</button>
|
||||
<SaveControls
|
||||
disabled={starting}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "tracking" && activeTab === "versioning" ? (
|
||||
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{/* กลุ่มข้อความสถานะ */}
|
||||
{hasUncommitted ? (
|
||||
<MessageBar intent="warning">
|
||||
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
|
||||
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
|
||||
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
|
||||
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
|
||||
disabled={saving}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<GitCommitHorizontal className="size-4" aria-hidden />
|
||||
Save & Version
|
||||
</button>
|
||||
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
<SaveControls
|
||||
disabled={saving}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
|
||||
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
|
||||
|
||||
<VersionHistoryPanel
|
||||
versions={versions}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
downloadingVersionId={downloadingVersionId}
|
||||
downloadingPdfVersionId={downloadingPdfVersionId}
|
||||
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
|
||||
onDownload={(version) => void handleDownload(version)}
|
||||
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
|
||||
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
|
||||
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
|
||||
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
|
||||
) : null}
|
||||
|
||||
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
|
||||
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
|
||||
เปิดค้างขณะสลับแท็บ) */}
|
||||
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
|
||||
<>
|
||||
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
|
||||
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
|
||||
<ConfirmVersionDialog
|
||||
open={confirmOpen}
|
||||
busy={saving}
|
||||
onConfirm={(message) => {
|
||||
setConfirmOpen(false);
|
||||
void handleSaveAndVersion(message);
|
||||
}}
|
||||
{...(closeSupported
|
||||
? {
|
||||
onConfirmClose: (message: string) => {
|
||||
setConfirmOpen(false);
|
||||
void handleSaveAndVersion(message, true);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
onDismiss={() => setConfirmOpen(false)}
|
||||
/>
|
||||
|
||||
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
|
||||
<ConfirmVersionDialog
|
||||
open={startConfirmOpen}
|
||||
busy={starting}
|
||||
title="เริ่มติดตามเอกสารนี้?"
|
||||
description={
|
||||
documentTemplateId
|
||||
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
|
||||
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
||||
}
|
||||
confirmLabel="เริ่มติดตาม (version 1)"
|
||||
onConfirm={(message) => {
|
||||
setStartConfirmOpen(false);
|
||||
void handleStartVersioning(message);
|
||||
}}
|
||||
onDismiss={() => setStartConfirmOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskpaneApp;
|
||||
411
components/wordaddin/TemplateToolsPanel.tsx
Normal file
411
components/wordaddin/TemplateToolsPanel.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react";
|
||||
|
||||
import {
|
||||
applyTestValues,
|
||||
insertBreakAtSelection,
|
||||
insertParagraphAtSelection,
|
||||
listContentControls,
|
||||
selectContentControl,
|
||||
supportsAllContentControlTypes,
|
||||
type ScannedField,
|
||||
} from "@/wordaddin/services/templateService";
|
||||
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
|
||||
/**
|
||||
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template
|
||||
* สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking:
|
||||
* 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง
|
||||
* 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที
|
||||
* โดยไม่ต้อง render ผ่าน /api/template/render)
|
||||
* 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง
|
||||
*
|
||||
* state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง
|
||||
* ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น)
|
||||
*/
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||
const BTN_SECONDARY =
|
||||
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */
|
||||
const BTN_ACTION =
|
||||
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
/** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */
|
||||
const BTN_INSERT =
|
||||
"focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) {
|
||||
/** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */
|
||||
const [fields, setFields] = useState<ScannedField[] | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [insertingParagraph, setInsertingParagraph] = useState(false);
|
||||
const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null);
|
||||
/** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
|
||||
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
|
||||
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(null);
|
||||
|
||||
const busy = scanning || applying || insertingParagraph || insertingBreak !== null;
|
||||
|
||||
/** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ
|
||||
* Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount
|
||||
* แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */
|
||||
useEffect(() => {
|
||||
onBusyChange?.(busy);
|
||||
return () => onBusyChange?.(false);
|
||||
}, [busy, onBusyChange]);
|
||||
|
||||
/** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */
|
||||
const uniqueTags = useMemo(() => {
|
||||
if (!fields) return [];
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const field of fields) {
|
||||
const tag = field.tag.trim();
|
||||
if (tag === "" || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}, [fields]);
|
||||
|
||||
const emptyTagCount = useMemo(
|
||||
() => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0),
|
||||
[fields],
|
||||
);
|
||||
|
||||
/** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */
|
||||
const duplicateTags = useMemo(() => {
|
||||
if (!fields) return [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const field of fields) {
|
||||
const tag = field.tag.trim();
|
||||
if (tag === "") continue;
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([tag]) => tag);
|
||||
}, [fields]);
|
||||
|
||||
/** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย
|
||||
* แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */
|
||||
const hasFilledValue = useMemo(
|
||||
() => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""),
|
||||
[uniqueTags, values],
|
||||
);
|
||||
|
||||
/**
|
||||
* สแกนเอกสาร + เติมค่าฟอร์ม
|
||||
* - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag
|
||||
* พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว)
|
||||
* - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม
|
||||
* และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ
|
||||
* **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่
|
||||
* (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน)
|
||||
*/
|
||||
const runScan = async (merge: boolean) => {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
if (!merge) {
|
||||
setSuccess(null);
|
||||
setWarning(null);
|
||||
}
|
||||
try {
|
||||
const scanned = await listContentControls();
|
||||
setFields(scanned);
|
||||
// Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล
|
||||
// ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน
|
||||
setCapabilityWarning(
|
||||
supportsAllContentControlTypes()
|
||||
? null
|
||||
: "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้",
|
||||
);
|
||||
setValues((previous) => {
|
||||
// สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่
|
||||
// (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป)
|
||||
const next: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const field of scanned) {
|
||||
const tag = field.tag.trim();
|
||||
if (tag === "" || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
next[tag] = merge && tag in previous ? previous[tag] : field.text;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (scanError) {
|
||||
setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ"));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = async (id: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
await selectContentControl(id);
|
||||
} catch (viewError) {
|
||||
setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ"));
|
||||
}
|
||||
};
|
||||
|
||||
/** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */
|
||||
const handleInsertParagraph = async () => {
|
||||
setInsertingParagraph(true);
|
||||
setError(null);
|
||||
try {
|
||||
await insertParagraphAtSelection();
|
||||
} catch (insertError) {
|
||||
setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ"));
|
||||
} finally {
|
||||
setInsertingParagraph(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsertBreak = async (breakKind: "line" | "page") => {
|
||||
setInsertingBreak(breakKind);
|
||||
setError(null);
|
||||
try {
|
||||
await insertBreakAtSelection(breakKind);
|
||||
} catch (insertError) {
|
||||
setError(
|
||||
getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"),
|
||||
);
|
||||
} finally {
|
||||
setInsertingBreak(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
const result = await applyTestValues(values);
|
||||
if (result.appliedCount === 0 && result.failures.length === 0) {
|
||||
setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่");
|
||||
} else {
|
||||
if (result.appliedCount > 0) {
|
||||
setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`);
|
||||
}
|
||||
const problems: string[] = [];
|
||||
if (result.missingTags.length > 0) {
|
||||
problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`);
|
||||
}
|
||||
if (result.failures.length > 0) {
|
||||
const first = result.failures[0];
|
||||
problems.push(
|
||||
`เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures
|
||||
.map((failure) => failure.tag)
|
||||
.join(", ")}) — ${first.reason}`,
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
setWarning(
|
||||
`${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ
|
||||
await runScan(true);
|
||||
} catch (applyError) {
|
||||
setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ"));
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
|
||||
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
|
||||
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
|
||||
|
||||
{/* §1 ตรวจสอบ Content Controls */}
|
||||
<div className="mt-2 flex items-baseline justify-between gap-2">
|
||||
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
|
||||
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_SECONDARY} w-full justify-center`}
|
||||
disabled={busy}
|
||||
aria-busy={scanning}
|
||||
onClick={() => void runScan(false)}
|
||||
>
|
||||
{scanning ? (
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ScanSearch className="size-4" aria-hidden />
|
||||
)}
|
||||
สแกนช่องในเอกสาร
|
||||
</button>
|
||||
|
||||
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
|
||||
<MessageBar intent="warning">
|
||||
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
|
||||
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
|
||||
{duplicateTags.length > 0
|
||||
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
|
||||
: ""}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{!fields && !scanning ? (
|
||||
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
|
||||
) : null}
|
||||
{fields && fields.length === 0 && !scanning ? (
|
||||
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
|
||||
) : null}
|
||||
|
||||
{fields && fields.length > 0 ? (
|
||||
<ol className="relative">
|
||||
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
|
||||
{fields.map((field, index) => (
|
||||
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-xs text-ink-faint">{index + 1}.</span>
|
||||
<span
|
||||
className="break-all text-sm font-medium text-ink"
|
||||
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
|
||||
>
|
||||
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
|
||||
</span>
|
||||
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
|
||||
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={busy}
|
||||
onClick={() => void handleView(field.id)}
|
||||
>
|
||||
<Crosshair className="size-3.5" aria-hidden />
|
||||
ดูช่อง
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
|
||||
{field.text.trim() === "" ? "(ว่าง)" : field.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
|
||||
{/* §2 กรอกค่าทดสอบ */}
|
||||
{uniqueTags.length > 0 ? (
|
||||
<>
|
||||
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
|
||||
<p className="text-sm text-ink-soft">
|
||||
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
|
||||
</p>
|
||||
<div className="flex flex-col gap-2" aria-busy={applying}>
|
||||
{uniqueTags.map((tag, index) => (
|
||||
<div key={tag} className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
|
||||
{tag}
|
||||
</label>
|
||||
<input
|
||||
id={`wordaddin-template-value-${index}`}
|
||||
className="genesis-input block w-full"
|
||||
value={values[tag] ?? ""}
|
||||
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full justify-center`}
|
||||
disabled={busy || !hasFilledValue}
|
||||
onClick={() => void handleApply()}
|
||||
>
|
||||
<PenLine className="size-4" aria-hidden />
|
||||
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
|
||||
</button>
|
||||
</>
|
||||
) : fields ? (
|
||||
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
|
||||
)}
|
||||
|
||||
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
|
||||
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
|
||||
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_INSERT}
|
||||
disabled={busy}
|
||||
aria-busy={insertingParagraph}
|
||||
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
|
||||
onClick={() => void handleInsertParagraph()}
|
||||
>
|
||||
{insertingParagraph ? (
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CornerDownLeft className="size-4" aria-hidden />
|
||||
)}
|
||||
ย่อหน้า
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_INSERT}
|
||||
disabled={busy}
|
||||
aria-busy={insertingBreak === "line"}
|
||||
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
|
||||
onClick={() => void handleInsertBreak("line")}
|
||||
>
|
||||
{insertingBreak === "line" ? (
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<WrapText className="size-4" aria-hidden />
|
||||
)}
|
||||
แบ่งบรรทัด
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_INSERT}
|
||||
disabled={busy}
|
||||
aria-busy={insertingBreak === "page"}
|
||||
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
|
||||
onClick={() => void handleInsertBreak("page")}
|
||||
>
|
||||
{insertingBreak === "page" ? (
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FilePlus className="size-4" aria-hidden />
|
||||
)}
|
||||
แบ่งหน้า
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplateToolsPanel;
|
||||
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Download, FileText, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
|
||||
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
|
||||
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
|
||||
*/
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime())
|
||||
? iso
|
||||
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
|
||||
const BTN_ACTION =
|
||||
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
versions,
|
||||
loading,
|
||||
error,
|
||||
downloadingVersionId,
|
||||
downloadingPdfVersionId,
|
||||
onDownloadPdf,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
versions: VersionRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||
downloadingVersionId: string | null;
|
||||
downloadingPdfVersionId: string | null;
|
||||
onDownloadPdf: (version: VersionRecord) => void;
|
||||
onDownload: (version: VersionRecord) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="mt-4 mb-2 flex items-baseline justify-between">
|
||||
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
|
||||
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
|
||||
</div>
|
||||
|
||||
{loading ? <Spinner label="กำลังโหลด…" /> : null}
|
||||
|
||||
{error ? (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={onRetry}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && versions.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
|
||||
) : null}
|
||||
|
||||
{versions.length > 0 ? (
|
||||
<ol className="relative">
|
||||
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
|
||||
{versions.length > 1 ? (
|
||||
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
|
||||
) : null}
|
||||
{versions.map((version, index) => {
|
||||
const isLatest = index === 0;
|
||||
return (
|
||||
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
|
||||
{isLatest ? (
|
||||
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingVersionId === version.versionId}
|
||||
onClick={() => onDownload(version)}
|
||||
>
|
||||
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
|
||||
{downloadingVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
)}
|
||||
ดาวน์โหลด
|
||||
</button>
|
||||
{version.hasPdf ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingPdfVersionId === version.versionId}
|
||||
onClick={() => onDownloadPdf(version)}
|
||||
>
|
||||
{downloadingPdfVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
)}
|
||||
PDF
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{version.message ? (
|
||||
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
|
||||
{version.message}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-xs text-ink-soft">
|
||||
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionHistoryPanel;
|
||||
131
components/wordaddin/WordAddinGate.tsx
Normal file
131
components/wordaddin/WordAddinGate.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { signInForDemo } from "@/wordaddin/services/authService";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TaskpaneApp from "./TaskpaneApp";
|
||||
|
||||
/**
|
||||
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
|
||||
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
|
||||
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
|
||||
* Office ที่ module top-level จึงปลอดภัย)
|
||||
*
|
||||
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
|
||||
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
|
||||
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
|
||||
*/
|
||||
|
||||
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
|
||||
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
|
||||
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
|
||||
const ONREADY_FALLBACK_MS = 5_000;
|
||||
|
||||
/**
|
||||
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
|
||||
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
|
||||
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
|
||||
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
|
||||
*/
|
||||
let officeReadyPromise: Promise<boolean> | null = null;
|
||||
|
||||
function ensureOfficeJs(): Promise<boolean> {
|
||||
if (!officeReadyPromise) {
|
||||
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||
if (typeof Office !== "undefined") {
|
||||
void Office.onReady(() => resolve(true));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||
const onReady = () => {
|
||||
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||
void Office.onReady(() => {
|
||||
clearTimeout(fallbackTimer);
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
script.onload = onReady;
|
||||
script.onerror = () => {
|
||||
clearTimeout(fallbackTimer);
|
||||
officeReadyPromise = null;
|
||||
script.remove();
|
||||
resolve(false);
|
||||
};
|
||||
script.src = OFFICE_JS_SRC;
|
||||
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
|
||||
script.id = "office-js";
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
return officeReadyPromise;
|
||||
}
|
||||
|
||||
type GateStatus = "loading" | "ready" | "error";
|
||||
|
||||
export function WordAddinGate() {
|
||||
const [status, setStatus] = useState<GateStatus>("loading");
|
||||
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
|
||||
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
|
||||
signInForDemo();
|
||||
setDemo(demoParam);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void ensureOfficeJs()
|
||||
.then((ok) => {
|
||||
if (!cancelled) setStatus(ok ? "ready" : "error");
|
||||
})
|
||||
.catch(() => {
|
||||
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
|
||||
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TaskpaneApp demo={demo ?? undefined} />;
|
||||
}
|
||||
|
||||
export default WordAddinGate;
|
||||
94
db/schema.ts
94
db/schema.ts
|
|
@ -253,12 +253,76 @@ export const annotationVersions = pgTable(
|
|||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
||||
);
|
||||
|
||||
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
|
||||
|
||||
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
|
||||
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
|
||||
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
|
||||
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
|
||||
docId: text("doc_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
|
||||
);
|
||||
|
||||
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
|
||||
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
|
||||
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
|
||||
export const documentVersions = pgTable(
|
||||
"document_versions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
documentId: uuid("document_id")
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: "cascade" }),
|
||||
versionNumber: integer("version_number").notNull(),
|
||||
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
|
||||
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
|
||||
clientVersionId: text("client_version_id"),
|
||||
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
|
||||
contentHash: text("content_hash").notNull(),
|
||||
/** commit message (FR-13) — trim แล้วว่าง = NULL */
|
||||
message: text("message"),
|
||||
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
|
||||
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
|
||||
fields: jsonb("fields").$type<Record<string, string>>(),
|
||||
templateId: text("template_id"),
|
||||
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
|
||||
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
|
||||
fileName: text("file_name"),
|
||||
docxObjectKey: text("docx_object_key").notNull(),
|
||||
pdfObjectKey: text("pdf_object_key"),
|
||||
docxSize: integer("docx_size").notNull(),
|
||||
pdfSize: integer("pdf_size"),
|
||||
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
|
||||
userId: text("user_id").notNull(),
|
||||
userName: text("user_name").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
|
||||
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
|
||||
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||||
|
||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||||
export type DocumentRow = typeof documents.$inferSelect;
|
||||
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||||
|
||||
export type AnnotationDTO = {
|
||||
id: string;
|
||||
|
|
@ -371,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
|||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||||
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||||
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||||
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||||
export type DocumentVersionDTO = {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
createdByUserName: string;
|
||||
contentHash: string;
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean;
|
||||
};
|
||||
|
||||
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||||
return {
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
createdByUserId: row.userId,
|
||||
createdByUserName: row.userName,
|
||||
contentHash: row.contentHash,
|
||||
fields: row.fields,
|
||||
message: row.message,
|
||||
hasPdf: row.pdfObjectKey !== null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
|
||||
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
|
||||
#
|
||||
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
|
||||
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
|
||||
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
|
||||
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: judes-note-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
|
||||
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
|
||||
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
|
||||
POSTGRES_DB: ecourt-note
|
||||
ports:
|
||||
- "54322:5432"
|
||||
volumes:
|
||||
- judes-note-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
|
||||
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
|
||||
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
|
||||
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
|
||||
minio:
|
||||
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: judes-note-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
|
||||
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
|
||||
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
|
||||
- "19001:9001" # console ดู bucket/object ผ่าน browser
|
||||
volumes:
|
||||
- judes-note-minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
|
||||
judes-note-db-data:
|
||||
name: judes-note-db-data
|
||||
judes-note-minio-data:
|
||||
name: judes-note-minio-data
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# postinstall รัน scripts/patch-pdf-worker.mjs + scripts/copy-swagger-ui.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/
|
||||
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
|
||||
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
|
||||
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||
RUN npm ci
|
||||
|
||||
|
|
@ -16,13 +17,26 @@ COPY . .
|
|||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
|
||||
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs
|
||||
# + ดาวน์โหลด Office.js runtime ล่าสุด (public/wordaddin/office-js/) ของ taskpane wordaddin
|
||||
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
|
||||
ENV MINIO_ENDPOINT=127.0.0.1
|
||||
ENV MINIO_PORT=19000
|
||||
ENV MINIO_USE_SSL=false
|
||||
ENV MINIO_ACCESS_KEY=minioadmin
|
||||
ENV MINIO_SECRET_KEY=minioadmin
|
||||
ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54
|
||||
ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e
|
||||
ENV DOCVER_TOKEN_TTL_SECONDS=604800
|
||||
ENV MINIO_BUCKET=docver
|
||||
ENV DOCVER_LINK_TTL_SECONDS=900
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
|
@ -31,10 +45,14 @@ ENV NODE_ENV=production \
|
|||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||
# Next 14.2 รวม public/ (pdf.worker, ฟอนต์ Sarabun, samples PDF) ไว้ใน standalone แล้ว
|
||||
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
|
||||
# Next 14.2 standalone **ไม่รวม public/ และ .next/static เอง** — ต้องคัดลอกเองทั้งคู่
|
||||
# (ก่อนหน้านี้เข้าใจผิดว่า Next รวม public/ ให้: จริง ๆ มันหลุดเข้ามาแค่บางส่วนผ่าน output
|
||||
# file tracing ที่ตาม fs.readFile ของ server code (public/note จาก lib/server-pdf.ts,
|
||||
# ฟอนต์ Sarabun จาก export route) — public/wordaddin/office-js ไม่มีใครอ่านผ่าน fs
|
||||
# จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||
|
|
|
|||
215
docs/PLAN-ADDIN-AUTH.md
Normal file
215
docs/PLAN-ADDIN-AUTH.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`)
|
||||
|
||||
> วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md`
|
||||
|
||||
## 0. บริบท/เป้าหมาย
|
||||
|
||||
เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp
|
||||
`token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement:
|
||||
|
||||
- e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256`
|
||||
- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network)
|
||||
- **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage
|
||||
ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป
|
||||
- ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ —
|
||||
ไม่ต้องรองรับสองแบบพร้อมกัน)
|
||||
|
||||
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27)
|
||||
|
||||
| # | เรื่อง | ข้อตกลง |
|
||||
|---|---|---|
|
||||
| D1 | addin ยืนยันกับ `/token` อย่างไร | **ขยาย `POST /api/documents/token` รับ ticket เพิ่ม** — body `{ticket_data, HmacSha256}` server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า `x-api-key` เดิม (e-Court server-to-server) ใช้ต่อคู่กัน |
|
||||
| D2 | โค้ดซ้ำสองฝั่ง | แชร์ส่วน pure ผ่าน **`utils/docver-ticket.ts`** (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก `lib/`") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = `node:crypto` |
|
||||
| D3 | client-side HMAC | ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET` — **ยอมรับว่าค่าใน bundle สกัดได้** จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify |
|
||||
| D4 | รูปแบบ HMAC | sign บน **string ดิบของ `ticket_data` ตามที่อ่านได้จาก property** (ห้าม trim/normalize) · digest เป็น **hex lowercase 64 ตัว** — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน) |
|
||||
| D5 | `tid` | = `template` ของ claims (`tid` optional → `template: null`) · map `uid→userid`, `uname→username`, `fid` ตรง ๆ |
|
||||
| D6 | token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด | เก็บ `exp` ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → **renew อัตโนมัติ** · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access |
|
||||
| D7 | localStorage | key เดียว `wordaddin-auth` เก็บ JSON `{v:1, userId, userName, tokens: {[fid]: {token, exp}}}` · เจอ user ใหม่ = **เคลียร์ทั้งก้อน** (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern `versioningService`) |
|
||||
| D8 | "disable Add-in ทั้งหมด" | = phase `no-access` ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ |
|
||||
| D9 | ช่วง mock (`NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง) | addin ยิงขอ token จริงที่ same-origin `withBasePath("/api/documents/token")` (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ |
|
||||
| D10 | เครื่องมือทดสอบ | มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (`scripts/make-ticket-docx.mjs`) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย |
|
||||
|
||||
## 2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp)
|
||||
|
||||
Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive):
|
||||
|
||||
```
|
||||
ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}
|
||||
HmacSha256 = <hex 64 ตัว lowercase> = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim)
|
||||
```
|
||||
|
||||
| field | type | เงื่อนไข |
|
||||
|---|---|---|
|
||||
| `exp` | int (unix วินาที) | บังคับ · `exp <= now` = หมดอายุ |
|
||||
| `fid` | string | บังคับ · trim 1–200 · เป็น `docId` ของทุก operation ของกลุ่ม docver |
|
||||
| `uid` | string | บังคับ · trim 1–100 |
|
||||
| `uname` | string | บังคับ · trim 1–200 |
|
||||
| `tid` | string | optional · ≤100 · template ต้นทาง (ไม่มี = `template: null`) |
|
||||
|
||||
- field อื่นปนมา = reject (allowlist ตาม convention ของ repo)
|
||||
- secret ปัจจุบัน = `"super_secret"` — **e-Court + `ADDIN_TICKET_SECRET` (server) +
|
||||
`NEXT_PUBLIC_ADDIN_TICKET_SECRET` (client) ต้องตรงกันทั้งสาม**
|
||||
- ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data`
|
||||
ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access)
|
||||
|
||||
## 3. ฝั่ง server — `POST /api/documents/token` รับ ticket เพิ่ม
|
||||
|
||||
Branch ตาม header (ตัวตั้ง = `x-api-key`):
|
||||
|
||||
| เคส | พฤติกรรม |
|
||||
|---|---|
|
||||
| มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) |
|
||||
| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` |
|
||||
| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) |
|
||||
| HMAC ผิด / parse ticket ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) |
|
||||
| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 |
|
||||
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
|
||||
|
||||
- การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern
|
||||
`verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ
|
||||
- middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้
|
||||
|
||||
## 4. โค้ดแชร์ — `utils/docver-ticket.ts` (pure, ไม่มี dependency เลย)
|
||||
|
||||
```ts
|
||||
export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string }
|
||||
export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string }
|
||||
export function isTicketExpired(exp: number, nowSec?: number): boolean // exp <= now
|
||||
export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$
|
||||
export function timingSafeEqualHex(a: string, b: string): boolean // constant-time บน hex ที่ normalize แล้ว
|
||||
export type HmacSha256Hex = (message: string, secret: string) => Promise<string> // adapter ที่สองฝั่งฉีด
|
||||
```
|
||||
|
||||
- `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน —
|
||||
schema drift กันได้จากฝั่งเดียว
|
||||
|
||||
## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement)
|
||||
|
||||
`TaskpaneApp.detect()` → `authService.signInWithTicket({ticketData, ticketHmac})`:
|
||||
|
||||
```
|
||||
1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties)
|
||||
2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์"
|
||||
parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข"
|
||||
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
|
||||
3. session = localStorage["wordaddin-auth"] (fallback memory)
|
||||
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
|
||||
· ticket หมดอายุ → no-access "ticket หมดอายุ"
|
||||
· เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
|
||||
· เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
|
||||
5. uid ตรงกัน (คนเดิม):
|
||||
· tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
|
||||
— token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
|
||||
— token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
|
||||
· tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
|
||||
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
|
||||
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
|
||||
```
|
||||
|
||||
- `authService` surface ใหม่: `signInWithTicket` / `getAccessToken` (token ของ fid ปัจจุบัน) /
|
||||
`renewTokenFromTicket` / `getUsername` — ถอด `signInFromDocument` + `decodeJwtField` สำหรับ uid
|
||||
(ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ `exp` ของ JWT ตอนเก็บ token)
|
||||
- `apiClient` ตอนโดน 401: `renewTokenFromTicket()` → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี
|
||||
ticket = `AuthRequiredError` (UI ไป no-access — เดิมมีอยู่แล้ว)
|
||||
- `?addindemo` ยังใช้ได้ (`signInForDemo` คงเดิม)
|
||||
|
||||
## 6. ไฟล์ที่สร้าง/แต้/ถอด
|
||||
|
||||
**สร้าง**
|
||||
- `utils/docver-ticket.ts` — §4
|
||||
- `lib/addin-ticket.ts` — server-only: `addinTicketSecret()` (env `ADDIN_TICKET_SECRET` trim หรือ default
|
||||
`"super_secret"`) + `verifyAddinTicket(ticketData, hmacHex, secret, nowSec?)` (HMAC `node:crypto` +
|
||||
timing-safe + parse + exp) + `ticketToDocverClaims(ticket)` — ห้าม import `@/db`/minio (unit test รันได้เปล่า ๆ)
|
||||
- `wordaddin/services/ticketService.ts` — client: `verifyTicketHmacWeb` (crypto.subtle), session store
|
||||
(`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken`
|
||||
(POST `withBasePath("/api/documents/token")` body ticket)
|
||||
- `scripts/make-ticket-docx.mjs` — §9
|
||||
- `tests/utils/docver-ticket.test.ts`, `tests/lib/addin-ticket.test.ts` (vitest — pattern `tests/lib/docver-token.test.ts`)
|
||||
|
||||
**แต้**
|
||||
- `app/api/documents/token/route.ts` — branch ตาม §3
|
||||
- `wordaddin/config.ts` — `ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"`
|
||||
- `wordaddin/services/metadataService.ts` — ถอด `ECOURT_TOKEN_PROPERTY`/`ECOURT_OWNER_PROPERTY` →
|
||||
`TICKET_DATA_PROPERTY = "ticket_data"` + `TICKET_HMAC_PROPERTY = "HmacSha256"` (`readTrackedProperties`
|
||||
อ่านคู่นี้แทน `ecourtToken`/`ecourtOwner`)
|
||||
- `wordaddin/services/authService.ts` — rewrite ตาม §5
|
||||
- `wordaddin/services/apiClient.ts` — renew-retry ตอน 401
|
||||
- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access 3 แบบ
|
||||
- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket)
|
||||
- `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md`
|
||||
|
||||
**ถอด**
|
||||
- ระบบ `token`/`owner` ทั้งเส้น (properties + `signInFromDocument` + `decodeJwtField` ส่วน uid) —
|
||||
แทนด้วย ticket เต็มตัว
|
||||
|
||||
## 7. Env
|
||||
|
||||
| env | default | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `ADDIN_TICKET_SECRET` | `SECRET` ของกลุ่ม template → `"super_secret"` | server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ `ADDIN_TICKET_SECRET` → `SECRET` (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → `"super_secret"` · **production ต้องตั้งค่าใหม่** (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ |
|
||||
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน |
|
||||
|
||||
> หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template
|
||||
> (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม
|
||||
> `exp` แล้ว HMAC hex lowercase ผ่าน `lib/lib-utils.ts` + stamp ลงไฟล์ด้วย
|
||||
> `setCustomDocProps` ของ `lib/lib-tmpl.ts` อ่าน env `SECRET` กับ `TICKET_SEC` default
|
||||
> 300 วิ — ไฟล์ยังมี property `Ed25519` เพิ่มด้วยแต่ out of scope ของแผนนี้)
|
||||
|
||||
(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที
|
||||
แต่ comment เตือน production ชัด ๆ ทั้ง `.env.example` และแผนนี้)
|
||||
|
||||
## 8. Edge cases
|
||||
|
||||
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
|
||||
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
|
||||
- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว
|
||||
(เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้)
|
||||
- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
|
||||
- **exp == now** → หมดอายุ
|
||||
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
|
||||
- **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น
|
||||
state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว
|
||||
- **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ)
|
||||
|
||||
## 9. สคริปต์ทดสอบ — `scripts/make-ticket-docx.mjs`
|
||||
|
||||
Dev tool รันมือ `node scripts/make-ticket-docx.mjs <file.docx> [options]`:
|
||||
|
||||
- option: `--fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-secret>` และ
|
||||
`--tracked` (stamp `SavedByAddIn`+`DocumentTrackingId` เพื่อทดสอบ phase tracking ด้วย)
|
||||
- ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง `docProps/custom.xml`
|
||||
ผ่าน **pizzip** (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี
|
||||
(พร้อม override ใน `[Content_Types].xml`) · เขียนทับไฟล์เดิม**เฉพาะเมื่อส่ง `--in-place`**
|
||||
ไม่งั้นเขียนไฟล์ใหม่ `<name>.ticket.docx` (กฎ non-destructive ผูกกับ `public/` เท่านั้นแต่ถือหลักเดียวกัน)
|
||||
- default secret/exp สอดคล้อง .env ตัวอย่าง
|
||||
|
||||
## 10. Verify criteria
|
||||
|
||||
1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง)
|
||||
2. `npx tsc --noEmit` ผ่าน
|
||||
3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม
|
||||
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
|
||||
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
|
||||
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" ·
|
||||
exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) ·
|
||||
ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ ·
|
||||
เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
|
||||
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
||||
|
||||
## 11. ลำดับงาน
|
||||
|
||||
1. `utils/docver-ticket.ts` + `tests/utils/docver-ticket.test.ts`
|
||||
2. `lib/addin-ticket.ts` + `tests/lib/addin-ticket.test.ts`
|
||||
3. `app/api/documents/token/route.ts` + `lib/openapi.ts` → verify 10.3 ด้วย curl
|
||||
4. `wordaddin/config.ts` + `metadataService.ts` (properties)
|
||||
5. `ticketService.ts` + `authService.ts` (rewrite)
|
||||
6. `TaskpaneApp.tsx` (detect + ข้อความ) + `apiClient.ts` (renew-retry)
|
||||
7. `scripts/make-ticket-docx.mjs`
|
||||
8. env/docs/CLAUDE.md sync → verify 10.1–10.5
|
||||
|
||||
## 12. Out of scope
|
||||
|
||||
- ปลด mock `versioningService` (ยัง mock ต่อ — token/flow ที่ทำรอบนี้พร้อมใช้วันปลด)
|
||||
- ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid) ของกลุ่ม docver —
|
||||
เป็นเรื่องของวันปลด mock (token ผูก fid แต่ mock ยังเรียกด้วย GUID — ไม่กระทบ auth)
|
||||
- UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)
|
||||
230
docs/PLAN-DOCVER.md
Normal file
230
docs/PLAN-DOCVER.md
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||||
|
||||
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
|
||||
> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
|
||||
|
||||
## 0. บริบท/เป้าหมาย
|
||||
|
||||
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
|
||||
`wordaddin/services/versioningService.ts`):
|
||||
|
||||
- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid`
|
||||
(string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก
|
||||
endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
|
||||
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
|
||||
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
||||
response)
|
||||
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
||||
- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22
|
||||
— เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
|
||||
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
|
||||
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
|
||||
พาหะ identity เดียว
|
||||
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
|
||||
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
|
||||
ของ N10) · ไม่มี CORS
|
||||
|
||||
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
||||
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
||||
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
|
||||
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
|
||||
|
||||
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
|
||||
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
|
||||
(client-only zone) type define เองใน `db/schema.ts`/`lib`
|
||||
|
||||
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
||||
middleware เหมือน `api/template`)
|
||||
|
||||
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
|
||||
|
||||
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
|
||||
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
|
||||
|
||||
**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header
|
||||
`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties
|
||||
ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน
|
||||
`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`)
|
||||
แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`)
|
||||
· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ
|
||||
เดียวกัน · body ผิดรูป = 400)
|
||||
|
||||
JSON body (allowlist — key อื่น = 400 กัน typo):
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
|
||||
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
|
||||
| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid |
|
||||
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
|
||||
|
||||
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
|
||||
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
|
||||
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
|
||||
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
|
||||
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
|
||||
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
|
||||
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
|
||||
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
|
||||
|
||||
### `POST /api/documents/versions` — อัปโหลด version ใหม่
|
||||
|
||||
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ
|
||||
`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป
|
||||
|
||||
multipart/form-data:
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `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 }` (≤100 keys, value ≤10,000) |
|
||||
| `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 นั้นแทน
|
||||
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
|
||||
"สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
|
||||
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
|
||||
(= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server**
|
||||
(client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
|
||||
|
||||
### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token)
|
||||
|
||||
response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in +
|
||||
ส่วนเสริม `createdByUserName`):
|
||||
|
||||
```ts
|
||||
{
|
||||
versionId: string; // = id ของ row (uuid)
|
||||
versionNumber: number;
|
||||
createdAt: string; // ISO-8601
|
||||
createdByUserId: string; // = user_id
|
||||
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
|
||||
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
|
||||
}
|
||||
```
|
||||
|
||||
- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ
|
||||
N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
|
||||
|
||||
### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf`
|
||||
|
||||
- format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400
|
||||
- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id
|
||||
ข้ามเอกสาร) — ไม่เจอ = 404
|
||||
- `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
|
||||
- response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET**
|
||||
(อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ
|
||||
ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
|
||||
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
|
||||
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
|
||||
|
||||
### ตาราง error
|
||||
|
||||
| เหตุ | สถานะ |
|
||||
|---|---|
|
||||
| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ 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 |
|
||||
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
|
||||
|
||||
## 2. ตาราง (db/schema.ts)
|
||||
|
||||
```
|
||||
documents — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
|
||||
document_versions — id uuid PK · document_id uuid FK→documents CASCADE
|
||||
· version_number int NOT NULL (+ UNIQUE(document_id, version_number))
|
||||
· client_version_id · content_hash NOT NULL · message · fields jsonb
|
||||
· template_id · file_name · docx_object_key NOT NULL · pdf_object_key
|
||||
· docx_size int NOT NULL · pdf_size int · user_id NOT NULL
|
||||
· user_name NOT NULL · created_at
|
||||
+ index (document_id, created_at)
|
||||
```
|
||||
|
||||
- **`documents` ≠ `files`** ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน
|
||||
(ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
|
||||
- `content_hash`/`docx_size` เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน
|
||||
insert เสมอ · `hasPdf` **ไม่มี column** — derive จาก `pdf_object_key !== null`
|
||||
- object key ใน MinIO: `docver/<versionId>/document.docx` / `document.pdf` — **uuid ล้วน**
|
||||
ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · `versionId` สร้าง
|
||||
ด้วย `randomUUID()` ฝั่ง server แล้วส่งเป็น `id` ตอน insert (object เขียนไปก่อนแล้ว)
|
||||
|
||||
## 3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||
|
||||
เลือก: **insert-on-conflict + `SELECT … FOR UPDATE` ใน transaction เดียว** (ใช้ `.for("update")`
|
||||
ที่ repo ใช้อยู่แล้วใน `app/actions/annotations.ts`):
|
||||
|
||||
1. `INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING` — สร้างเอกสารถ้ายังไม่มี
|
||||
2. `SELECT id FROM documents WHERE doc_id = … FOR UPDATE` — serialize ผู้จ่ายเลขรายเอกสาร
|
||||
(unique `doc_id` → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
|
||||
3. `SELECT max(version_number) + 1` แล้ว insert row version
|
||||
|
||||
ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) ·
|
||||
`pg_advisory_xact_lock` (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE
|
||||
`(document_id, version_number)` เก็บไว้เป็น safety net
|
||||
|
||||
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า
|
||||
|
||||
validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId`
|
||||
→ **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
|
||||
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
|
||||
|
||||
- crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) —
|
||||
**ไม่ทำ GC รอบนี้** บันทึกไว้เป็นงานอนาคต
|
||||
- ไม่มี idempotency บน `clientVersionId` — retry หลัง network timeout เกิด version ใหม่
|
||||
(client แก้เลขจาก response เองได้) ยอมรับได้
|
||||
|
||||
## 5. Carve-out จาก invariant #4 (CLAUDE.md)
|
||||
|
||||
กลุ่ม API นี้ **เขียน DB ผ่าน route handler** (ไม่ใช่ Server Action) และไม่ publish SSE
|
||||
ไม่ `revalidatePath` — ตาราง `documents`/`document_versions` เป็นของกลุ่มนี้ล้วน ไม่มีหน้า
|
||||
note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ
|
||||
read-only ของ N10 แต่ตัวนี้เขียนได้)
|
||||
|
||||
## 6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)
|
||||
|
||||
| env | default | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `MINIO_ENDPOINT` | — บังคับ | host อย่างเดียว ไม่มี scheme |
|
||||
| `MINIO_PORT` | `9000` | dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง) |
|
||||
| `MINIO_USE_SSL` | `false` | |
|
||||
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
|
||||
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
|
||||
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
|
||||
| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ |
|
||||
| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
|
||||
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
|
||||
|
||||
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
|
||||
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
|
||||
ตอนดาวน์โหลด
|
||||
|
||||
## 7. ไฟล์ของกลุ่มนี้
|
||||
|
||||
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
|
||||
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
|
||||
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
|
||||
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer`
|
||||
+ `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`)
|
||||
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
||||
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
|
||||
- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key)
|
||||
- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token)
|
||||
- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET
|
||||
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
|
||||
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
|
||||
- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure
|
||||
|
||||
## 8. Out of scope รอบนี้
|
||||
|
||||
- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
|
||||
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
|
||||
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
|
||||
89
docs/WORDADDIN-DEV.md
Normal file
89
docs/WORDADDIN-DEV.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
|
||||
|
||||
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
|
||||
|
||||
**ทางเข้า:** เปิด `/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 จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
|
||||
|
||||
## ไฟล์หลัก
|
||||
|
||||
| ที่ | คือ |
|
||||
|---|---|
|
||||
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
|
||||
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
|
||||
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler |
|
||||
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) |
|
||||
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
|
||||
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
|
||||
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
|
||||
|
||||
## Env
|
||||
|
||||
| ตัวแปร | default | ใช้เมื่อไหร่ |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) |
|
||||
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) |
|
||||
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** |
|
||||
|
||||
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
|
||||
|
||||
manifest ต้องชี้ https — 2 ทางเลือก:
|
||||
|
||||
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
|
||||
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
|
||||
|
||||
## Sideload ใน Word Desktop (Windows)
|
||||
|
||||
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
|
||||
|
||||
1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word
|
||||
2. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
|
||||
3. ชื่อไฟล์ไม่สำคัญ — Word สแกน `*.xml` ทุกไฟล์ในโฟลเดอร์ โดย `manifest.template.xml` คือ dev manifest อยู่แล้ว (URL localhost:3000) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout อยู่เสมอ — **ห้ามวาง xml manifest อื่นเพิ่มในโฟลเดอร์นี้** (GUID เดียวกันสองไฟล์ = Word สับสน)
|
||||
|
||||
เครื่องที่ไม่มี repo (production/เครื่องผู้ใช้):
|
||||
|
||||
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
|
||||
2. เอาไฟล์ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
|
||||
3. ทำขั้น Trust Center ข้างบนด้วยโฟลเดอร์นั้น > restart Word > **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
|
||||
|
||||
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
|
||||
|
||||
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
|
||||
|
||||
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
|
||||
|
||||
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม้นอก host (host เป็น null) → `detect()` อ่าน custom properties ไม่ได้ (ไม่มี `Word.run`) → เห็น `no-access` — ตรวจ state machine ได้แค่นี้ใน browser ปกติ ส่วน phase `untracked`/`tracking` ต้องใช้ demo ด้านล่าง (หรือ sideload ใน Word จริง)
|
||||
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
|
||||
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
|
||||
|
||||
## ทดสอบ e-Court ticket (auth ทางเข้าเดียว)
|
||||
|
||||
ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ
|
||||
|
||||
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template):
|
||||
|
||||
```bash
|
||||
# สร้าง <file>.ticket.docx (ไม่แตะไฟล์เดิม) exp +5 นาที เท่า TICKET_SEC ของตัว stamp
|
||||
node scripts/make-ticket-docx.mjs path/to/file.docx --fid DEMO_FID_001 --uid oom --uname "สรวิชญ์" --tid T12
|
||||
# ดู custom properties ที่มีในไฟล์
|
||||
node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list
|
||||
# ทดสอบ phase tracking ด้วย (stamp SavedByAddIn + DocumentTrackingId เพิ่ม)
|
||||
node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place
|
||||
```
|
||||
|
||||
แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7
|
||||
|
||||
หมายเหตุ: การขอ 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` ของสคริปต์
|
||||
|
||||
## การสลับกลับของจริง (วันหน้า)
|
||||
|
||||
- **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/`
|
||||
|
||||
## Docker
|
||||
|
||||
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ runner stage คัดลอก `public/` เข้า image เอง (`COPY /app/public ./public` — **Next 14.2 standalone ไม่รวม `public/` ให้อัตโนมัติ** ต้อง copy เองตามเอกสาร deploy ของ Next; เคยเข้าใจผิดว่า Next รวมให้ เพราะบางไฟล์หลุดเข้า image ผ่าน output file tracing ของ Next ที่ตาม `fs.readFile` ใน server code — แต่ `public/wordaddin/office-js` ไม่มีใครอ่านผ่าน fs จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||
|
||||
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile
|
||||
319
docs/addin-ui-flow.md
Normal file
319
docs/addin-ui-flow.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
|
||||
|
||||
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
|
||||
|
||||
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. State machine หลัก
|
||||
|
||||
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
|
||||
|
||||
ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
|
||||
|
||||
**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`)
|
||||
|
||||
```
|
||||
checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access
|
||||
│
|
||||
├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked
|
||||
└─(ticket ผ่าน + tracked doc)────────→ tracking
|
||||
```
|
||||
|
||||
การเปลี่ยน phase อื่นๆ:
|
||||
|
||||
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
|
||||
- ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access`
|
||||
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ)
|
||||
|
||||
State ที่ UI ถือนอกเหนือจาก phase:
|
||||
|
||||
| State | ความหมาย |
|
||||
|---|---|
|
||||
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
|
||||
| `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header |
|
||||
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
|
||||
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
|
||||
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
|
||||
| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) |
|
||||
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
|
||||
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
|
||||
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
|
||||
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
|
||||
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
|
||||
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
|
||||
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
|
||||
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
|
||||
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
|
||||
|
||||
---
|
||||
|
||||
## 2. UI แต่ละ phase
|
||||
|
||||
### Header (ทุก phase)
|
||||
|
||||
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
|
||||
- ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title`
|
||||
|
||||
### `checking`
|
||||
|
||||
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
|
||||
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
|
||||
|
||||
### `no-access`
|
||||
|
||||
- ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้"
|
||||
- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ):
|
||||
- ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"
|
||||
- ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย"
|
||||
- ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"
|
||||
- ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง"
|
||||
- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น
|
||||
|
||||
### `untracked`
|
||||
|
||||
เรียงจากบนลงล่าง:
|
||||
|
||||
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
|
||||
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
|
||||
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
|
||||
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
|
||||
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
|
||||
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
|
||||
7. `flowError` → MessageBar **error**
|
||||
|
||||
### `tracking`
|
||||
|
||||
เรียงจากบนลงล่าง:
|
||||
|
||||
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
|
||||
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
|
||||
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
|
||||
4. `flowError` → MessageBar **error**
|
||||
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
|
||||
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
|
||||
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
|
||||
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
|
||||
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
|
||||
|
||||
---
|
||||
|
||||
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
|
||||
|
||||
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
|
||||
|
||||
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
|
||||
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
|
||||
- สถานะตามลำดับ:
|
||||
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
|
||||
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
|
||||
- `saveError` → MessageBar **error**
|
||||
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว
|
||||
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
|
||||
|
||||
พฤติกรรมปุ่ม:
|
||||
|
||||
| ปุ่ม | Flow |
|
||||
|---|---|
|
||||
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
|
||||
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
|
||||
|
||||
---
|
||||
|
||||
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
|
||||
|
||||
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
|
||||
|
||||
| Prop | Save & Version | Start Versioning |
|
||||
|---|---|---|
|
||||
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
|
||||
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
|
||||
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
|
||||
|
||||
ภายใน dialog:
|
||||
|
||||
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
|
||||
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
|
||||
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
|
||||
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
|
||||
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
|
||||
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
|
||||
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
|
||||
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
|
||||
|
||||
---
|
||||
|
||||
## 5. `VersionHistoryPanel` (FR-4)
|
||||
|
||||
- Header: icon history + "Version history" (semibold)
|
||||
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
|
||||
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
|
||||
- `Badge v{versionNumber}` (สี brand)
|
||||
- timestamp รูปแบบ medium date + short time ตาม locale
|
||||
- `โดย {createdByUserId}`
|
||||
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
|
||||
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
|
||||
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
|
||||
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
|
||||
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow หลักพร้อมข้อความ progress
|
||||
|
||||
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
|
||||
|
||||
### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่
|
||||
|
||||
1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
|
||||
2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ
|
||||
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
|
||||
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
|
||||
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
|
||||
|
||||
### Start Versioning (FR-1) — จาก `untracked`
|
||||
|
||||
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
|
||||
|
||||
1. `กำลังบันทึก metadata ของเอกสาร…`
|
||||
2. `กำลังอัปเดต version history ในไฟล์…`
|
||||
3. `กำลังบันทึกไฟล์…`
|
||||
4. `กำลังอ่านค่า fields จากเอกสาร…`
|
||||
5. `กำลังอัปโหลด version 1…`
|
||||
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
|
||||
7. `กำลังอัปโหลด version…`
|
||||
|
||||
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
|
||||
|
||||
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
|
||||
|
||||
### Save & Version (FR-2) — จาก `tracking`
|
||||
|
||||
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
|
||||
|
||||
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
|
||||
|
||||
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
|
||||
|
||||
### Download version (FR-4/FR-7)
|
||||
|
||||
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
|
||||
|
||||
### Auto-save (FR-17)
|
||||
|
||||
- เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้)
|
||||
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
|
||||
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
|
||||
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
|
||||
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
|
||||
|
||||
---
|
||||
|
||||
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
|
||||
|
||||
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
|
||||
|
||||
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
|
||||
|
||||
multipart/form-data:
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
|
||||
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
|
||||
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
|
||||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
|
||||
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
|
||||
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
|
||||
|
||||
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
|
||||
|
||||
### `GET /api/documents/{documentId}/versions` — history
|
||||
|
||||
response: `VersionRecord[]` เรียงใหม่ → เก่า:
|
||||
|
||||
```ts
|
||||
interface VersionRecord {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string; // ISO-8601
|
||||
createdByUserId: string;
|
||||
contentHash: string; // SHA256 hex
|
||||
message: string | null; // commit message (FR-13)
|
||||
hasPdf?: boolean;
|
||||
fields: FieldsSnapshot | null;
|
||||
}
|
||||
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
|
||||
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
|
||||
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
|
||||
```
|
||||
|
||||
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
|
||||
|
||||
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
|
||||
|
||||
### Auth — e-Court ticket (ไม่มี login)
|
||||
|
||||
ยกเลิก 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`
|
||||
|
||||
---
|
||||
|
||||
## 8. กติกา error ที่ UI ต้องรองรับ
|
||||
|
||||
1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
|
||||
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
|
||||
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
|
||||
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
|
||||
|
||||
---
|
||||
|
||||
## 9. ข้อควรระวังที่กระทบหน้าตา UI
|
||||
|
||||
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
|
||||
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
|
||||
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
|
||||
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
|
||||
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
|
||||
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
|
||||
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
|
||||
|
||||
---
|
||||
|
||||
## 10. แท็บ "เครื่องมือ Template" (2026-10-19)
|
||||
|
||||
เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน
|
||||
|
||||
### §1 ตรวจสอบ Content Controls
|
||||
|
||||
- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง
|
||||
- สรุปหัวกลุ่ม: "N ช่อง"
|
||||
- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag"
|
||||
- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร")
|
||||
|
||||
### §2 กรอกค่าทดสอบ
|
||||
|
||||
- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-<index>`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ tag นั้น + ปุ่ม primary `เขียนค่าลงเอกสาร` (icon PenLine → "กำลังเขียนค่า…" ตอนรัน)
|
||||
- **ช่องที่เว้นว่าง = ไม่ถูกแตะ** (กันเผลอล้างค่าจริง) · เขียนทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่องแบบ mail merge) · อ่าน id+tag สดรอบเดียวแล้วเขียน**แยก Word.run ราย tag** — tag ที่ Word ปฏิเสธ fail เฉพาะตัว เหลืออื่นยังเขียนได้ (ผลลัพธ์: `appliedCount` = จำนวนช่องที่เขียนจริง + `missingTags` ไม่พบช่อง + `failures` พร้อม code/errorLocation จาก OfficeExtension.Error) · UI รวมเป็น warning พร้อมชี้นำ "ถ้าเอกสารถูกล็อค (Review → Restrict Editing) ให้ปลดก่อน" — เคสจริงที่เจอ: error `0x800A16C6` บนเอกสารที่ `make-protected.mjs` ล็อคไว้ (readOnly + enforcement)
|
||||
- ผลลัพธ์: success "เขียนค่าทดสอบลงแล้ว N ช่อง" (N = จำนวนช่องที่เขียนจริง — tag ซ้ำหลายช่องนับทุกช่อง) + warning missingTags ถ้ามี → **สแกนใหม่โดยคงค่าฟอร์ม** (เฉพาะ tag ที่ยังอยู่ในเอกสาร — tag ที่ถูกลบไปตัดทิ้ง) ให้แก้ต่อ · ปุ่ม disabled เมื่อยังไม่มีช่องที่กรอกค่าเลย · หลังเขียนสำเร็จ `notifyDocumentChanged()` ตั้ง debounce auto-save 3 วิ ส่วน banner FR-3 ตั้งเมื่อ periodic tick ถัดไปของ autoSaveService เห็น document dirty (พฤติกรรมเดิมของ service — ไม่ใช่ทันที)
|
||||
|
||||
### §3 แทรกย่อหน้า / ตัวแบ่ง (2026-10-19)
|
||||
|
||||
- 3 ปุ่มแนวตั้ง (icon บน label ล่าง, grid 3 คอลัมน์, `title` อธิบายเต็ม): **ย่อหน้า** (CornerDownLeft — `insertParagraph("", "After")` เหมือนกด Enter) · **แบ่งบรรทัด** (WrapText — `insertBreak("Line", "After")` เหมือน Shift+Enter จัดที่อยู่/รายการในย่อหน้าเดิม) · **แบ่งหน้า** (FilePlus — เหมือน Ctrl+Enter เอกสารหลายหน้า: `insertBreak("Page", "After")` ก่อน ถ้า host ปฏิเสธ E_FAIL 0x80004005 (บางบริบท เช่น cursor ในตาราง — เคสจริงบน T3 2026-10-19) สลับไป `insertOoxml` แบบ w:p สมบูรณ์ครอบ `<w:br w:type="page"/>` — fragment ต้องเป็น block-level เต็มตัว ใช้ run เดี่ยวก็ยัง E_FAIL)
|
||||
- กดแล้วทำที่ cursor ทันที (ไม่มี success message — เห็นผลในเอกสารเลย) · icon สลับเป็น spinner ตอนรันตัวนั้น · ทุกปุ่ม disable ด้วย `busy` ร่วมกับเครื่องมืออื่น · fallback error: "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ"
|
||||
|
||||
### หมายเหตุ: ไม่มีปุ่มสลับ formatting marks (และไม่มีชุดแทรกอักขระพิเศษ)
|
||||
|
||||
- **Office.js ไม่มี API สลับแสดง/ซ่อน formatting marks** (¶ ท้ายย่อหน้า/จุดช่องว่าง) — เป็น application setting ของ Word ที่ WordApi ไม่เปิดให้ add-in แตะ ผู้ใช้กด **Ctrl+Shift+8** หรือปุ่ม **¶ บนแท็บ Home** ของ Word เอง (workflow จริงของผู้ใช้: แทรกย่อหน้าเพิ่ม → เปิด marks ตรวจ → ซ่อนกลับ — จึงมีปุ่มแทรกย่อหน้าให้ ส่วน marks ใช้ของ Word)
|
||||
- เคยมีชุด "แทรกอักขระพิเศษ" (เลขไทย ๐–๙ + สัญลักษณ์) ต่อท้าย panel แล้ว**ถอดออก 2026-10-19** ตามคำขอผู้ใช้ — ที่ต้องการจริงคือ formatting marks ซึ่งทำผ่าน add-in ไม่ได้ (อ่านบันทึกเต็มใน git history รอบนั้น)
|
||||
- เคยมีเครื่องมือ **"สร้างช่องจากข้อความที่เลือก"** (dialog ตั้ง tag + `insertContentControl` ครอบ selection + `validateTag` + `TagInputDialog` + unit test ของ validateTag) แล้ว**ถอดออกทั้งเส้น 2026-10-19** ตามคำขอผู้ใช้ — การสร้างช่องใหม่ทำใน Word ด้วย Developer tab แทน
|
||||
|
||||
### ข้อความ error กลางของ panel (MessageBar intent error)
|
||||
|
||||
- สแกนพัง: fallback "สแกนช่องในเอกสารไม่สำเร็จ" · ดูช่องพัง: "ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่" / fallback "เลือกช่องไม่สำเร็จ" · เขียนค่าพัง: fallback "เขียนค่าลงเอกสารไม่สำเร็จ" · แทรกพัง: fallback "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" (แบ่งหน้าที่พังทั้งสองวิธีจะรวมสาเหตุของทั้ง insertBreak และ OOXML fallback ในข้อความเดียว)
|
||||
|
||||
### พฤติกรรมใน browser ธรรมดา / `?addindemo`
|
||||
|
||||
ทุกปุ่มที่แตะเอกสาร (`สแกน`/`ดูช่อง`/`เขียนค่า`/`ย่อหน้า`/`แบ่งบรรทัด`/`แบ่งหน้า`) จบที่ error MessageBar ("Word is not defined") — ตามปรัชญา demo ของ repo เหมือนปุ่ม Save & Version ส่วน toggle/สลับแท็บ/ฟอร์มค่าทดสอบ ทดสอบได้ครบใน browser
|
||||
44
docs/requirement/addin-workflow.md
Normal file
44
docs/requirement/addin-workflow.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# ขั้นตอนการทำงานของ Addin
|
||||
|
||||
## 1. เมื่อเปิดเอกสาร
|
||||
|
||||
- อ่าน custom property ของ word
|
||||
1. ticket_data ซึ่งจะมีข้อมูลเบื้องต้น ดังนี้
|
||||
```jsonc
|
||||
{
|
||||
"exp": 1793067433,
|
||||
"fid": "T12_FD155_3s-long_ZZZ",
|
||||
"tid": "T12",
|
||||
"uid": "oom",
|
||||
"uname": "สรวิชญ์",
|
||||
}
|
||||
```
|
||||
2. นำค่า ticket_data ที่เป็น string ก่อนนำไปแปลง json ทำการ sign HMAC-SHA256 โดยใช้ secret "super_secret" ซึ่งต้องรองรับการ config ค่านี้ในอนาคต
|
||||
3. นำค่าที่ sign ได้ มาเทียบกับค่า property "HmacSha256"
|
||||
- ขั้นตอนการทำ authen
|
||||
```
|
||||
ขั้นตอน
|
||||
1. เมื่อเปิดไฟล์ Word ให้อ่าน Custom Property 2 ค่าที่ฝังมาในไฟล์ คือ value จริง และ Hash Value (HMAC) เพื่อตรวจสอบว่าไฟล์สร้างผ่านระบบอย่างถูกต้อง
|
||||
2. ถ้า HMAC ไม่ตรงกัน แสดงว่าไฟล์ถูกแก้ไข ให้ Add-in disable การทำงานทั้งหมด
|
||||
3. ถ้า HMAC ตรงกัน ให้เก็บค่า uId, uName, fId, tid ไว้ แล้วตรวจสอบว่า userId นี้มีใน localStorage อยู่แล้วหรือไม่
|
||||
4. ถ้ายังไม่มีให้ตรวจค่า Exp ที่ได้จากไฟล์ Word
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
- ยังไม่หมดอายุ → เก็บใน localStorage
|
||||
- userName: ค่าจาก uName
|
||||
- คีย์คือ fid: เอาค่า {
|
||||
"userid": uid,
|
||||
"username": uname,
|
||||
"fid": fid (เป็น string),
|
||||
"tid": tid
|
||||
}
|
||||
ไปทำการขอ token ที่ /api/documents/token แล้วนำค่าที่ได้มาเก็บไว้ที่ตัวแปรนี้ เพื่อให้อ่านค่าไปใช้ให้ตรงกับค่า fid ที่ได้รับ เนื่องจาก api ในส่วน /documents จะต้องแนบ token ไปไม่ส่งค่าไปตรงๆ เพื่อความถูกต้อง
|
||||
5. ถ้ามี Session อยู่แล้ว ให้ตรวจว่า userId ตรงกับ Session หรือไม่
|
||||
6. ถ้า userId ไม่ตรงกัน (user ใหม่) ให้ตรวจค่า Expire
|
||||
- ยังไม่หมดอายุ → เคลียร์ Session เดิม แล้วสร้าง Session 2 ค่าใหม่
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
7. ถ้า userId ตรงกัน (คนเดิม) ให้ตรวจว่า fid มีใน localStorage แล้วหรือไม่
|
||||
- มีแล้ว (กลับมาทำเอกสารเดิม) → ผ่านได้เลย ไม่ต้องตรวจ Expire
|
||||
- ยังไม่มี (เอกสารใหม่) → ตรวจค่า Expire
|
||||
- ยังไม่หมดอายุ → ทำการขอ token แล้วเก็บใน localStorage ตามขั้นตอนก่อนหน้า
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
```
|
||||
29
drizzle/0005_icy_bloodaxe.sql
Normal file
29
drizzle/0005_icy_bloodaxe.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CREATE TABLE "document_versions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"version_number" integer NOT NULL,
|
||||
"client_version_id" text,
|
||||
"content_hash" text NOT NULL,
|
||||
"message" text,
|
||||
"fields" jsonb,
|
||||
"template_id" text,
|
||||
"file_name" text,
|
||||
"docx_object_key" text NOT NULL,
|
||||
"pdf_object_key" text,
|
||||
"docx_size" integer NOT NULL,
|
||||
"pdf_size" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"user_name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"doc_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
|
||||
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");
|
||||
735
drizzle/meta/0005_snapshot.json
Normal file
735
drizzle/meta/0005_snapshot.json
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
{
|
||||
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
|
||||
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.annotation_replies": {
|
||||
"name": "annotation_replies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_replies_annotation_idx": {
|
||||
"name": "annotation_replies_annotation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "annotation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_replies_annotation_id_annotations_id_fk": {
|
||||
"name": "annotation_replies_annotation_id_annotations_id_fk",
|
||||
"tableFrom": "annotation_replies",
|
||||
"tableTo": "annotations",
|
||||
"columnsFrom": [
|
||||
"annotation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotation_versions": {
|
||||
"name": "annotation_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_versions_file_idx": {
|
||||
"name": "annotation_versions_file_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_versions_file_id_files_id_fk": {
|
||||
"name": "annotation_versions_file_id_files_id_fk",
|
||||
"tableFrom": "annotation_versions",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotations": {
|
||||
"name": "annotations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved": {
|
||||
"name": "resolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotations_file_page_idx": {
|
||||
"name": "annotations_file_page_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "page_index",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotations_file_id_files_id_fk": {
|
||||
"name": "annotations_file_id_files_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.bookmarks": {
|
||||
"name": "bookmarks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookmarks_file_id_files_id_fk": {
|
||||
"name": "bookmarks_file_id_files_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.document_versions": {
|
||||
"name": "document_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"document_id": {
|
||||
"name": "document_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_number": {
|
||||
"name": "version_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_version_id": {
|
||||
"name": "client_version_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_hash": {
|
||||
"name": "content_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fields": {
|
||||
"name": "fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"template_id": {
|
||||
"name": "template_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_name": {
|
||||
"name": "file_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_object_key": {
|
||||
"name": "docx_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_object_key": {
|
||||
"name": "pdf_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_size": {
|
||||
"name": "docx_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_size": {
|
||||
"name": "pdf_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"document_versions_document_number_key": {
|
||||
"name": "document_versions_document_number_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "version_number",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"document_versions_document_created_idx": {
|
||||
"name": "document_versions_document_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"document_versions_document_id_documents_id_fk": {
|
||||
"name": "document_versions_document_id_documents_id_fk",
|
||||
"tableFrom": "document_versions",
|
||||
"tableTo": "documents",
|
||||
"columnsFrom": [
|
||||
"document_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"doc_id": {
|
||||
"name": "doc_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"documents_doc_id_key": {
|
||||
"name": "documents_doc_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "doc_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.files": {
|
||||
"name": "files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_url": {
|
||||
"name": "pdf_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type_id": {
|
||||
"name": "type_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"files_type_id_idx": {
|
||||
"name": "files_type_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"files_external_id_key": {
|
||||
"name": "files_external_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "external_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.annotation_type": {
|
||||
"name": "annotation_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"HIGHLIGHT",
|
||||
"DRAW",
|
||||
"COMMENT",
|
||||
"SHAPE",
|
||||
"REDACTION",
|
||||
"TEXTBOX",
|
||||
"STAMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@
|
|||
"when": 1790072946313,
|
||||
"tag": "0004_right_ted_forrester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792467537332,
|
||||
"tag": "0005_icy_bloodaxe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
124
lib/addin-ticket.ts
Normal file
124
lib/addin-ticket.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ
|
||||
// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้
|
||||
// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server
|
||||
// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court
|
||||
// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256
|
||||
// ของกลุ่ม template: hex lowercase บน string ดิบ)
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน
|
||||
// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts)
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import {
|
||||
isTicketExpired,
|
||||
normalizeHexDigest,
|
||||
parseDocverTicket,
|
||||
type DocverTicket,
|
||||
} from "@/utils/docver-ticket";
|
||||
import type { DocverTokenClaims } from "@/lib/docver-token";
|
||||
|
||||
/**
|
||||
* secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load
|
||||
* ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp
|
||||
* ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret"
|
||||
* (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ
|
||||
* ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ
|
||||
*/
|
||||
export function addinTicketSecret(): string {
|
||||
return (
|
||||
process.env.ADDIN_TICKET_SECRET?.trim() ||
|
||||
process.env.SECRET?.trim() ||
|
||||
"super_secret"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
|
||||
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
|
||||
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
|
||||
*/
|
||||
export function verifyAddinTicket(
|
||||
ticketData: string,
|
||||
hmacHex: string,
|
||||
secret: string,
|
||||
nowSec: number = Math.floor(Date.now() / 1000),
|
||||
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
||||
const digest = normalizeHexDigest(hmacHex);
|
||||
if (digest === null) return { ok: false };
|
||||
|
||||
// เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง
|
||||
// ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw)
|
||||
const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest();
|
||||
const expectedHash = createHash("sha256").update(expected).digest();
|
||||
const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest();
|
||||
if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false };
|
||||
|
||||
const parsed = parseDocverTicket(ticketData);
|
||||
if (!parsed.ok) return { ok: false };
|
||||
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
|
||||
return { ok: true, ticket: parsed.ticket };
|
||||
}
|
||||
|
||||
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
|
||||
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
||||
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
||||
}
|
||||
|
||||
/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */
|
||||
const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const;
|
||||
|
||||
export type TicketTokenBodyResult =
|
||||
| { ok: true; ticketData: string; hmacHex: string }
|
||||
| { ok: false; kind: "missing" | "invalid"; error: string };
|
||||
|
||||
/**
|
||||
* parse body ของ POST /api/documents/token ทางเข้า ticket
|
||||
* - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน)
|
||||
* - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ)
|
||||
*/
|
||||
export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "invalid",
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => {
|
||||
const value = record[name];
|
||||
if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing
|
||||
if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid
|
||||
return value;
|
||||
};
|
||||
|
||||
const ticketData = read("ticket_data");
|
||||
if (ticketData === null) {
|
||||
return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" };
|
||||
}
|
||||
if (ticketData === "") {
|
||||
return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" };
|
||||
}
|
||||
if (ticketData.length > 2048) {
|
||||
return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" };
|
||||
}
|
||||
|
||||
const hmacHex = read("HmacSha256");
|
||||
if (hmacHex === null) {
|
||||
return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" };
|
||||
}
|
||||
if (hmacHex === "") {
|
||||
return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" };
|
||||
}
|
||||
if (hmacHex.length > 128) {
|
||||
return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" };
|
||||
}
|
||||
|
||||
return { ok: true, ticketData, hmacHex };
|
||||
}
|
||||
227
lib/document-versioning.ts
Normal file
227
lib/document-versioning.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
|
||||
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
|
||||
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
|
||||
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||
|
||||
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
|
||||
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
|
||||
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
|
||||
export const DOCVER_MESSAGE_MAX = 2048;
|
||||
|
||||
export const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
|
||||
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
|
||||
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
const FORM_FIELDS = [
|
||||
"file",
|
||||
"pdf",
|
||||
"clientVersionId",
|
||||
"fields",
|
||||
"message",
|
||||
"fileName",
|
||||
] as const;
|
||||
|
||||
export type DocverError = { ok: false; error: string };
|
||||
|
||||
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
|
||||
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
|
||||
export function docverError(message: string, status: number): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
|
||||
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
|
||||
export function isDocxBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
|
||||
}
|
||||
|
||||
export function isPdfBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
||||
}
|
||||
|
||||
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
|
||||
export function parseVersionIdParam(raw: string):
|
||||
| { ok: true; versionId: string }
|
||||
| DocverError {
|
||||
const versionId = raw.trim().toLowerCase();
|
||||
if (!UUID_RE.test(versionId)) {
|
||||
return { ok: false, error: "versionId ต้องเป็น UUID" };
|
||||
}
|
||||
return { ok: true, versionId };
|
||||
}
|
||||
|
||||
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
|
||||
export function parseDownloadFormat(raw: string | null):
|
||||
| { ok: true; format: "docx" | "pdf" }
|
||||
| DocverError {
|
||||
const format = raw?.trim() ?? "";
|
||||
if (format === "") return { ok: true, format: "docx" };
|
||||
if (format === "docx" || format === "pdf") return { ok: true, format };
|
||||
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
||||
}
|
||||
|
||||
function trimOrNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
|
||||
function formString(form: FormData, field: string):
|
||||
| { ok: true; value: string | null }
|
||||
| DocverError {
|
||||
const entry = form.get(field);
|
||||
if (entry === null) return { ok: true, value: null };
|
||||
if (typeof entry !== "string") {
|
||||
return { ok: false, error: `field '${field}' ต้องเป็น string` };
|
||||
}
|
||||
return { ok: true, value: trimOrNull(entry) };
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
|
||||
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
|
||||
export function sanitizeDisplayFileName(raw: string | null): string | null {
|
||||
if (raw === null) return null;
|
||||
const base = raw.split(/[\\/]/).pop() ?? raw;
|
||||
return trimOrNull(base)?.slice(0, 200) ?? null;
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
|
||||
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
|
||||
export function sanitizeFilePartName(raw: string): string | null {
|
||||
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||
}
|
||||
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||
export function parseFieldsJson(raw: string):
|
||||
| { ok: true; fields: Record<string, string> | null }
|
||||
| DocverError {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
if (keys.length > 100) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
if (value.length > 10_000) {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
|
||||
export type ParsedVersionUploadForm = {
|
||||
clientVersionId: string | null;
|
||||
message: string | null;
|
||||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||
fileName: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
|
||||
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
|
||||
export function parseVersionUploadForm(form: FormData):
|
||||
| { ok: true; form: ParsedVersionUploadForm }
|
||||
| DocverError {
|
||||
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
|
||||
// downlevelIteration — array ธรรมดาไม่ต้อง)
|
||||
for (const key of Array.from(form.keys())) {
|
||||
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const clientVersionId = formString(form, "clientVersionId");
|
||||
if (!clientVersionId.ok) return clientVersionId;
|
||||
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
||||
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const message = formString(form, "message");
|
||||
if (!message.ok) return message;
|
||||
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
||||
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||
}
|
||||
|
||||
const rawFields = formString(form, "fields");
|
||||
if (!rawFields.ok) return rawFields;
|
||||
let fields: Record<string, string> | null = null;
|
||||
if (rawFields.value !== null) {
|
||||
const parsed = parseFieldsJson(rawFields.value);
|
||||
if (!parsed.ok) return parsed;
|
||||
fields = parsed.fields;
|
||||
}
|
||||
|
||||
const rawFileName = formString(form, "fileName");
|
||||
if (!rawFileName.ok) return rawFileName;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
form: {
|
||||
clientVersionId: clientVersionId.value,
|
||||
message: message.value,
|
||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||
fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
|
||||
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
|
||||
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
|
||||
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
|
||||
|
||||
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
|
||||
const ext = `.${kind}`;
|
||||
const ascii = (displayName ?? "")
|
||||
.replace(/[^\x20-\x7E]/g, "")
|
||||
.replace(/["\\]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
let asciiName: string = DEFAULT_FILE_NAMES[kind];
|
||||
if (/[A-Za-z0-9]/.test(ascii)) {
|
||||
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
|
||||
}
|
||||
const display = displayName?.trim();
|
||||
if (display) {
|
||||
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
|
||||
}
|
||||
return `attachment; filename="${asciiName}"`;
|
||||
}
|
||||
275
lib/docver-store.ts
Normal file
275
lib/docver-store.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
|
||||
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
|
||||
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
|
||||
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
|
||||
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
|
||||
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
|
||||
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { Client } from "minio";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
documents,
|
||||
documentVersions,
|
||||
type DocumentRow,
|
||||
type DocumentVersionRow,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
contentDispositionValue,
|
||||
DOCX_CONTENT_TYPE,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "@/lib/document-versioning";
|
||||
|
||||
type MinioConfig = {
|
||||
endPoint: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
|
||||
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
|
||||
function readConfig(): MinioConfig {
|
||||
const endPoint = process.env.MINIO_ENDPOINT?.trim();
|
||||
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
|
||||
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
|
||||
if (!endPoint || !accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
|
||||
);
|
||||
}
|
||||
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
|
||||
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
|
||||
return {
|
||||
endPoint,
|
||||
port: Number.isFinite(port) && port > 0 ? port : 9000,
|
||||
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
|
||||
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
|
||||
ttlSeconds:
|
||||
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
|
||||
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
|
||||
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
|
||||
const globalForMinio = globalThis as unknown as {
|
||||
docverClient?: { key: string; client: Client };
|
||||
docverBucketReady?: { bucket: string; promise: Promise<void> };
|
||||
};
|
||||
|
||||
function getClient(): { client: Client; config: MinioConfig } {
|
||||
const config = readConfig();
|
||||
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
|
||||
if (globalForMinio.docverClient?.key !== key) {
|
||||
globalForMinio.docverClient = {
|
||||
key,
|
||||
client: new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { client: globalForMinio.docverClient.client, config };
|
||||
}
|
||||
|
||||
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
|
||||
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
|
||||
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
|
||||
async function ensureBucket(client: Client, bucket: string): Promise<void> {
|
||||
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
|
||||
const promise = (async () => {
|
||||
if (await client.bucketExists(bucket)) return;
|
||||
try {
|
||||
await client.makeBucket(bucket);
|
||||
} catch (err) {
|
||||
if (!(await client.bucketExists(bucket))) throw err;
|
||||
}
|
||||
})();
|
||||
globalForMinio.docverBucketReady = { bucket, promise };
|
||||
promise.catch(() => {
|
||||
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
|
||||
if (globalForMinio.docverBucketReady?.promise === promise) {
|
||||
globalForMinio.docverBucketReady = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return globalForMinio.docverBucketReady.promise;
|
||||
}
|
||||
|
||||
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
|
||||
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
|
||||
return {
|
||||
docx: `docver/${versionId}/document.docx`,
|
||||
pdf: `docver/${versionId}/document.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
export type PutVersionInput = {
|
||||
versionId: string;
|
||||
docx: { bytes: Buffer; fileName: string | null };
|
||||
pdf?: { bytes: Buffer; fileName: string | null };
|
||||
};
|
||||
|
||||
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
|
||||
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
|
||||
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
|
||||
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
|
||||
const { client, config } = getClient();
|
||||
await ensureBucket(client, config.bucket);
|
||||
const keys = versionObjectKeys(input.versionId);
|
||||
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
|
||||
"Content-Type": DOCX_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
|
||||
});
|
||||
if (!input.pdf) return;
|
||||
try {
|
||||
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
|
||||
"Content-Type": PDF_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
|
||||
});
|
||||
} catch (err) {
|
||||
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
|
||||
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
|
||||
export async function removeVersionObjects(
|
||||
versionId: string,
|
||||
hadPdf: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { client, config } = getClient();
|
||||
const keys = versionObjectKeys(versionId);
|
||||
await client.removeObject(config.bucket, keys.docx);
|
||||
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
|
||||
} catch (err) {
|
||||
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
|
||||
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
|
||||
* คำนวณ local ล้วนไม่มี network call */
|
||||
export async function presignVersionUrl(
|
||||
versionId: string,
|
||||
format: "docx" | "pdf",
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
const { client, config } = getClient();
|
||||
const key = versionObjectKeys(versionId)[format];
|
||||
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
|
||||
return {
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
|
||||
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
|
||||
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, documentId))
|
||||
.orderBy(desc(documentVersions.versionNumber));
|
||||
}
|
||||
|
||||
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
|
||||
export async function findVersionForDoc(
|
||||
docId: string,
|
||||
versionId: string,
|
||||
): Promise<DocumentVersionRow | null> {
|
||||
const [row] = await db
|
||||
.select({ version: documentVersions })
|
||||
.from(documentVersions)
|
||||
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
|
||||
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
|
||||
.limit(1);
|
||||
return row?.version ?? null;
|
||||
}
|
||||
|
||||
export type InsertVersionInput = {
|
||||
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
|
||||
versionId: string;
|
||||
docId: string;
|
||||
clientVersionId: string | null;
|
||||
contentHash: string;
|
||||
message: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
templateId: string | null;
|
||||
fileName: string | null;
|
||||
docxObjectKey: string;
|
||||
pdfObjectKey: string | null;
|
||||
docxSize: number;
|
||||
pdfSize: number | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
|
||||
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
|
||||
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
|
||||
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(documents)
|
||||
.values({ docId: input.docId })
|
||||
.onConflictDoNothing({ target: documents.docId });
|
||||
|
||||
const [doc] = await tx
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.docId, input.docId))
|
||||
.for("update");
|
||||
if (!doc) {
|
||||
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
|
||||
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
|
||||
}
|
||||
|
||||
const [latest] = await tx
|
||||
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, doc.id));
|
||||
|
||||
const [row] = await tx
|
||||
.insert(documentVersions)
|
||||
.values({
|
||||
id: input.versionId,
|
||||
documentId: doc.id,
|
||||
versionNumber: (latest?.max ?? 0) + 1,
|
||||
clientVersionId: input.clientVersionId,
|
||||
contentHash: input.contentHash,
|
||||
message: input.message,
|
||||
fields: input.fields,
|
||||
templateId: input.templateId,
|
||||
fileName: input.fileName,
|
||||
docxObjectKey: input.docxObjectKey,
|
||||
pdfObjectKey: input.pdfObjectKey,
|
||||
docxSize: input.docxSize,
|
||||
pdfSize: input.pdfSize,
|
||||
userId: input.userId,
|
||||
userName: input.userName,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
223
lib/docver-token.ts
Normal file
223
lib/docver-token.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
||||
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
||||
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
||||
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
||||
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
||||
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
||||
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
||||
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
||||
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
|
||||
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { docverError, type DocverError } from "@/lib/document-versioning";
|
||||
|
||||
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
||||
export type DocverTokenClaims = {
|
||||
userid: string;
|
||||
username: string;
|
||||
fid: string;
|
||||
template: string | null;
|
||||
};
|
||||
|
||||
function base64url(input: string): string {
|
||||
return Buffer.from(input, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = Buffer.from(segment, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
|
||||
export function issueDocverToken(
|
||||
claims: DocverTokenClaims,
|
||||
secret: string,
|
||||
ttlSeconds: number,
|
||||
): string {
|
||||
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const payload = base64url(
|
||||
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
|
||||
);
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
||||
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
||||
function claimsFromPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): DocverTokenClaims | null {
|
||||
const { userid, username, fid, template } = payload;
|
||||
if (typeof userid !== "string" || userid === "") return null;
|
||||
if (typeof username !== "string" || username === "") return null;
|
||||
if (typeof fid !== "string" || fid === "") return null;
|
||||
if (template !== null && typeof template !== "string") return null;
|
||||
return { userid, username, fid, template };
|
||||
}
|
||||
|
||||
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
|
||||
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
|
||||
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
|
||||
export function verifyDocverToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
|
||||
const segments = token.split(".");
|
||||
if (segments.length !== 3) return { ok: false };
|
||||
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
||||
|
||||
const header = decodeJsonSegment(headerSegment);
|
||||
if (header === null || header.alg !== "HS256") return { ok: false };
|
||||
|
||||
const expected = createHmac("sha256", secret)
|
||||
.update(`${headerSegment}.${payloadSegment}`)
|
||||
.digest();
|
||||
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
||||
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
||||
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
||||
const provided = createHash("sha256")
|
||||
.update(Buffer.from(signatureSegment, "base64url"))
|
||||
.digest();
|
||||
const expectedHash = createHash("sha256").update(expected).digest();
|
||||
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
|
||||
|
||||
const payload = decodeJsonSegment(payloadSegment);
|
||||
if (payload === null) return { ok: false };
|
||||
const exp = payload.exp;
|
||||
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
||||
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
||||
|
||||
const claims = claimsFromPayload(payload);
|
||||
if (claims === null) return { ok: false };
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
|
||||
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
|
||||
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||
export function requireDocverApiKey(request: Request): Response | null {
|
||||
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||
const envKey = process.env.DOCVER_API_KEY?.trim();
|
||||
// const envKey =
|
||||
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
||||
if (envKey === undefined || envKey === "") {
|
||||
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
||||
}
|
||||
const provided = request.headers.get("x-api-key");
|
||||
if (provided === null || provided === "") {
|
||||
return docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
const providedHash = createHash("sha256").update(provided).digest();
|
||||
const envHash = createHash("sha256").update(envKey).digest();
|
||||
return timingSafeEqual(providedHash, envHash)
|
||||
? null
|
||||
: docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
|
||||
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
||||
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
||||
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
||||
|
||||
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
||||
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
||||
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
||||
export function requireDocverBearer(
|
||||
request: Request,
|
||||
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
||||
const denied = () =>
|
||||
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
// const secret =
|
||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||
if (secret === undefined || secret === "") return denied();
|
||||
const header = request.headers.get("authorization");
|
||||
if (header === null) return denied();
|
||||
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
if (match === null) return denied();
|
||||
const verified = verifyDocverToken(match[1], secret);
|
||||
if (!verified.ok) return denied();
|
||||
return { ok: true, claims: verified.claims };
|
||||
}
|
||||
|
||||
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
||||
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
||||
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
||||
|
||||
export function parseTokenRequestBody(
|
||||
raw: unknown,
|
||||
): { ok: true; claims: DocverTokenClaims } | DocverError {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ok: false, error: "body ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawUserid = record.userid;
|
||||
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ userid (string)" };
|
||||
}
|
||||
const userid = rawUserid.trim();
|
||||
if (userid.length > 100) {
|
||||
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawUsername = record.username;
|
||||
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ username (string)" };
|
||||
}
|
||||
const username = rawUsername.trim();
|
||||
if (username.length > 200) {
|
||||
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawFid = record.fid;
|
||||
if (typeof rawFid !== "string" || rawFid.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ fid (string)" };
|
||||
}
|
||||
const fid = rawFid.trim();
|
||||
if (fid.length > 200) {
|
||||
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawTemplate = record.template ?? null;
|
||||
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
||||
return { ok: false, error: "template ต้องเป็น string" };
|
||||
}
|
||||
const template = rawTemplate?.trim() || null;
|
||||
if (template !== null && template.length > 100) {
|
||||
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
return { ok: true, claims: { userid, username, fid, template } };
|
||||
}
|
||||
1314
lib/lib-tmpl.ts
Executable file
1314
lib/lib-tmpl.ts
Executable file
File diff suppressed because it is too large
Load diff
112
lib/lib-utils.ts
Normal file
112
lib/lib-utils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
|
||||
import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from 'node:crypto';
|
||||
/**
|
||||
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
|
||||
*/
|
||||
export function generateHmacSha256(data: string | object, secretKey: string): string {
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
return createHmac('sha256', secretKey)
|
||||
.update(payload, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
|
||||
*/
|
||||
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
|
||||
const calculatedHmac = generateHmacSha256(data, secretKey);
|
||||
const a = Buffer.from(calculatedHmac, 'hex');
|
||||
const b = Buffer.from(expectedHmac, 'hex');
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
// ==========================================
|
||||
// ตัวอย่างการใช้งาน
|
||||
// ==========================================
|
||||
/*
|
||||
const secret = 'my-super-secret-key';
|
||||
const jsonData = {
|
||||
userId: 12345,
|
||||
action: 'transfer',
|
||||
amount: 500
|
||||
};
|
||||
|
||||
// 1. สร้าง HMAC
|
||||
const hmacSignature = generateHmacSha256(jsonData, secret);
|
||||
console.log('HMAC Signature (Hex):', hmacSignature);
|
||||
|
||||
// 2. ตรวจสอบ HMAC
|
||||
const isValid = verifyHmacSha256(jsonData, secret, hmacSignature);
|
||||
console.log('Is Signature Valid?:', isValid); // true
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign) ข้อมูลด้วย Ed25519 Private Key (Hex)
|
||||
* @param message ข้อความดิบที่ต้องการเซ็น (string หรือ Buffer)
|
||||
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
|
||||
*/
|
||||
export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
|
||||
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
|
||||
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
|
||||
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
|
||||
// PKCS#8 Header สำหรับ Ed25519 Private Key
|
||||
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||
const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]);
|
||||
|
||||
const privateKey = createPrivateKey({
|
||||
key: pkcs8Buffer,
|
||||
format: 'der',
|
||||
type: 'pkcs8',
|
||||
});
|
||||
|
||||
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||
const signatureBuffer = sign(null, dataBuffer, privateKey);
|
||||
return signatureBuffer.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
|
||||
* @param message ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
|
||||
* @param signatureHex ลายเซ็นในรูปแบบ Hex String
|
||||
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
||||
*/
|
||||
export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
|
||||
try {
|
||||
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
|
||||
|
||||
// SPKI Header สำหรับ Ed25519 Public Key (32 bytes)
|
||||
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
|
||||
const spkiBuffer = Buffer.concat([spkiHeader, rawPublicKey]);
|
||||
|
||||
const publicKey = createPublicKey({
|
||||
key: spkiBuffer,
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
|
||||
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||
const signatureBuffer = Buffer.from(signatureHex, 'hex');
|
||||
|
||||
return verify(null, dataBuffer, publicKey, signatureBuffer);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับเรียงคีย์ของ Object ตามตัวอักษรเพื่อไม่ให้โครงสร้าง JSON เพี้ยน
|
||||
*/
|
||||
export function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.reduce((result: Record<string, unknown>, key: string) => {
|
||||
result[key] = obj[key];
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
875
lib/openapi.ts
875
lib/openapi.ts
|
|
@ -1,5 +1,6 @@
|
|||
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API
|
||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
|
||||
// (จัด version เอกสาร)
|
||||
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
|
||||
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
|
||||
//
|
||||
|
|
@ -23,16 +24,26 @@ export const openApiSpec = {
|
|||
title: "Document Template & External API ของระบบ Judge's Note",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control) " +
|
||||
"และ API ภายนอกสำหรับ backend e-Court (N10)",
|
||||
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
|
||||
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
|
||||
},
|
||||
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
|
||||
tags: [
|
||||
{
|
||||
name: "template",
|
||||
description:
|
||||
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
|
||||
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
|
||||
"Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ",
|
||||
},
|
||||
{
|
||||
name: "documents",
|
||||
description:
|
||||
"จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " +
|
||||
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
|
||||
"ของกลุ่มนี้: ขอที่ POST /api/documents/token ได้ 2 ทาง (x-api-key + body claims " +
|
||||
"สำหรับระบบภายนอก หรือ body ticket_data+HmacSha256 สำหรับ add-in — PLAN-ADDIN-AUTH.md) " +
|
||||
"แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
|
||||
"และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " +
|
||||
"ไม่รับจาก URL/multipart form อีก",
|
||||
},
|
||||
{
|
||||
name: "Judge's Note",
|
||||
|
|
@ -64,53 +75,15 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/template/extract": {
|
||||
post: {
|
||||
tags: ["template"],
|
||||
summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx",
|
||||
description:
|
||||
"อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " +
|
||||
"ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "ไฟล์ .docx ที่มี Content Control",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "รายการแท็กพร้อมข้อความ",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ExtractResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/template/render": {
|
||||
post: {
|
||||
tags: ["template"],
|
||||
summary: "สร้างเอกสารจาก template (.docx) + data (JSON)",
|
||||
summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น",
|
||||
description:
|
||||
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
|
||||
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
|
||||
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)',
|
||||
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)<br/>'+
|
||||
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ</i>',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
|
@ -132,9 +105,23 @@ export const openApiSpec = {
|
|||
dataJson: {
|
||||
type: "string",
|
||||
description:
|
||||
"JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)",
|
||||
"JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)<br/>{\"info:\"{\"ticket_data\":\"JSON String\"},\"dc:\"{..},\"dc:\"{\"blackyear\": \"๙๐/๒๕๖๙\",...}}",
|
||||
example:
|
||||
'{"notname":"คำสั่งเรียก ให้ทำคำให้การ","blackno":"คดีหมายเลขดำที่","blackyear":"๑๐/๒๕๖๙"}',
|
||||
'',
|
||||
},
|
||||
secret: {
|
||||
type: "string",
|
||||
description:
|
||||
"ค่า secret สำหรับทดสอบการทำ HMAC-SHA256",
|
||||
example:
|
||||
'super_secret',
|
||||
},
|
||||
private_key: {
|
||||
type: "string",
|
||||
description:
|
||||
"Private key ในรูปแบบ HEX <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex",
|
||||
example:
|
||||
'8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -155,6 +142,410 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/template/extract": {
|
||||
post: {
|
||||
tags: ["template"],
|
||||
summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx ใช้สำหรับนักพัฒนาเท่านั้น",
|
||||
description:
|
||||
"อ่าน Content Control, bookmark , property ของ MS Word(docx) ออกมาแสดง" +
|
||||
"ค่าของ Content Control จะเป็น Plain Text ไม่มี markup ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+
|
||||
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ</i>',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "ไฟล์ .docx ที่มี Content Control",
|
||||
},
|
||||
secret: {
|
||||
type: "string",
|
||||
description:
|
||||
"ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property",
|
||||
example:
|
||||
"super_secret",
|
||||
},
|
||||
public_key: {
|
||||
type: "string",
|
||||
description:
|
||||
"Public key ในรูปแบบ HEX <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex",
|
||||
example:
|
||||
"66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "รายการแท็กพร้อมข้อความ",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ExtractResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/template/generate": {
|
||||
post: {
|
||||
tags: ["template"],
|
||||
summary: "สร้างเอกสาร DOCX จากข้อมูล JSON และ template ที่กำหนด",
|
||||
description:
|
||||
"เทมเพลตมีแท็กแบบพิเศษ(รองรับตัวแปร ลูป เงื่อนไข), Content Control เมื่อรวมกับข้อมูล(JSON) "+
|
||||
"จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล<br><ul>"+
|
||||
"<li>JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)</li>" +
|
||||
"<li>ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร </li>" +
|
||||
"<li>ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning</li>"+
|
||||
"</ul>",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["info"],
|
||||
properties: {
|
||||
info: {
|
||||
type: "object",
|
||||
required: ["ticket_data"],
|
||||
properties: {
|
||||
ticket_data: {
|
||||
type: "string",
|
||||
description:
|
||||
"JSON string ที่ต้อง parse ได้ และต้องมี tid, fid และ uid",
|
||||
example:
|
||||
'{"tid":"T12","fid":"DOC-2569-0001","uid":"123"}',
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
example: {
|
||||
"info": {
|
||||
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||
},
|
||||
"dc": {
|
||||
"Interpleader": true,
|
||||
"docname": "",
|
||||
"docabbr": "(ต. ๑๒)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
"court": "ศาลปกครองนครราชสีมา",
|
||||
"panels": [
|
||||
{
|
||||
"pname": "นายวิรุฬห์ จินตนะกุล",
|
||||
"pmname": "ตุลาการศาลปกครองกลาง",
|
||||
"pm": "ตุลาการเจ้าของสำนวน"
|
||||
},
|
||||
{
|
||||
"pname": "นายสัมฤทธิ์ อ่อนคำ",
|
||||
"pmname": "ตุลาการศาลปกครองสูงสุด"
|
||||
}
|
||||
],
|
||||
"pnameT": "",
|
||||
"pmnameT": "",
|
||||
"pmT": "",
|
||||
"acc_name": ""
|
||||
|
||||
},
|
||||
"cc": {
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "รร",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"interpleader": "สมชาย ใจดี",
|
||||
"acc3": "ผู้ร้องสอด",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "ไฟล์ DOCX ที่สร้างสำเร็จ",
|
||||
headers: {
|
||||
"Content-Disposition": {
|
||||
description: "ชื่อไฟล์สำหรับดาวน์โหลด",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
"Cache-Control": {
|
||||
description: "ป้องกันการ cache response",
|
||||
schema: { type: "string", example: "no-store, max-age=0" },
|
||||
},
|
||||
},
|
||||
content: {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"JSON ไม่มี info.ticket_data, ticket_data ไม่ใช่ string, JSON string ไม่ถูกต้อง หรือไม่มี tid/fid/uid",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
examples: {
|
||||
missingTicketData: {
|
||||
value: {
|
||||
error:
|
||||
"template/generate json.info.ticket_data not avaliable",
|
||||
},
|
||||
},
|
||||
missingIdentifiers: {
|
||||
value: {
|
||||
error:
|
||||
"template/generate require json.info.ticket_data.(fid|tid|uid)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "ไม่ได้ส่ง x-api-key หรือ API key ไม่ถูกต้อง",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Unauthorized: Invalid API Key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "ไม่สามารถดึงข้อมูลขึ้นมาได้",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "NotFound: Invalid template ID or incorrect storage path" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description:
|
||||
"เกิดข้อผิดพลาดระหว่างอ่าน template หรือสร้างเอกสาร",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Failed to generate document payload" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/template/download-docx": {
|
||||
get: {
|
||||
tags: ["template"],
|
||||
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง",
|
||||
description:
|
||||
"รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
|
||||
parameters: [
|
||||
{
|
||||
name: "fid",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "ID เอกสาร (File ID)",
|
||||
example: "T_no_warning",
|
||||
},
|
||||
{
|
||||
name: "tid",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "ID เทมเพลต (Template ID)",
|
||||
example: "T_X",
|
||||
},
|
||||
{
|
||||
name: "uid",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "รหัสผู้ใช้งาน (User ID)",
|
||||
example: "123",
|
||||
},
|
||||
{
|
||||
name: "uname",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "ชื่อผู้ใช้งาน (User Name)",
|
||||
example: "สมชาย ใจดี",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ",
|
||||
headers: {
|
||||
"Content-Disposition": {
|
||||
description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)",
|
||||
schema: { type: "string", example: 'attachment; filename="T_no_warning_123_xxx.docx"' },
|
||||
},
|
||||
"Cache-Control": {
|
||||
description: "การควบคุม Cache (no-store)",
|
||||
schema: { type: "string", example: "no-store, max-age=0" },
|
||||
},
|
||||
},
|
||||
content: {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
examples: {
|
||||
missingParams: { value: "Missing fid,tid,uid or uname" },
|
||||
invalidData: { value: "Invalid data format" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Data not found: /path/to/TMPL_PATH/T_no_warning.json" },
|
||||
},
|
||||
},
|
||||
},
|
||||
502: {
|
||||
description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)",
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
example: "Internal Server Error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["template"],
|
||||
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง",
|
||||
description:
|
||||
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+
|
||||
"จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["fid", "tid", "uid", "uname"],
|
||||
properties: {
|
||||
fid: { type: "string", description: "ID เอกสาร (File ID)",example:"T12_b2_IN" },
|
||||
tid: { type: "string", description: "ID เทมเพลต (Template ID)",example:"T12_X" },
|
||||
uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)", example:"john" },
|
||||
uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)", example:"John Doe"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["fid", "tid", "uid", "uname"],
|
||||
properties: {
|
||||
fid: { type: "string", description: "ID เอกสาร (File ID)" },
|
||||
tid: { type: "string", description: "ID เทมเพลต (Template ID)" },
|
||||
uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)" },
|
||||
uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ",
|
||||
headers: {
|
||||
"Content-Disposition": {
|
||||
description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)",
|
||||
schema: { type: "string", example: 'attachment; filename="T12_b2_IN_john_xxx.docx"' },
|
||||
},
|
||||
"Cache-Control": {
|
||||
description: "การควบคุม Cache (no-store)",
|
||||
schema: { type: "string", example: "no-store, max-age=0" },
|
||||
},
|
||||
},
|
||||
content: {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
examples: {
|
||||
missingParams: { value: "Missing fid,tid,uid or uname" },
|
||||
invalidData: { value: "Invalid data format" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Data not found: /path/to/TMPL_PATH/T12_b2_IN.json" },
|
||||
},
|
||||
},
|
||||
},
|
||||
502: {
|
||||
description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)",
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
example: "Internal Server Error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/note/external/documents": {
|
||||
post: {
|
||||
tags: ["external"],
|
||||
|
|
@ -217,6 +608,240 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/token": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
||||
description:
|
||||
"ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " +
|
||||
"JWT HS256 ฝัง claims {userid, username, fid, template} อายุตาม env " +
|
||||
"DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " +
|
||||
"templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " +
|
||||
"fid จึงบังคับ · ทางเข้า 2 ทาง (header x-api-key เป็นตัวตั้ง): " +
|
||||
"(1) ระบบภายนอก server-to-server — header x-api-key (env DOCVER_API_KEY) + body " +
|
||||
"claims ตรง ๆ (2) add-in ใน Word — ไม่มี x-api-key + body {ticket_data, " +
|
||||
"HmacSha256} ตามที่ e-Court stamp มากับไฟล์ — server ตรวจ HMAC-SHA256 ด้วย env " +
|
||||
"ADDIN_TICKET_SECRET (ไม่ตั้ง = env SECRET ไม่ตั้ง = \"super_secret\") และ exp " +
|
||||
"ของ ticket ก่อนออก token จาก claims ในตัว ticket เอง",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{ $ref: "#/components/schemas/TokenRequest" },
|
||||
{ $ref: "#/components/schemas/TicketTokenRequest" },
|
||||
],
|
||||
},
|
||||
examples: {
|
||||
"server-to-server ด้วย x-api-key (ครบทุก claim)": {
|
||||
value: {
|
||||
userid: "123",
|
||||
username: "Sorawit Bholsithi",
|
||||
fid: "3",
|
||||
template: "T12",
|
||||
},
|
||||
},
|
||||
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
|
||||
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
|
||||
},
|
||||
"add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": {
|
||||
value: {
|
||||
ticket_data:
|
||||
'{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}',
|
||||
HmacSha256:
|
||||
"1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/TokenResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: {
|
||||
description:
|
||||
"ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " +
|
||||
"ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/versions": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
|
||||
description:
|
||||
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " +
|
||||
"อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " +
|
||||
"พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " +
|
||||
"ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " +
|
||||
"สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " +
|
||||
"แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)",
|
||||
security: [{ DocverBearer: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "ไฟล์ .docx ของ version นี้ (บังคับ)",
|
||||
},
|
||||
pdf: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description:
|
||||
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
|
||||
},
|
||||
clientVersionId: {
|
||||
type: "string",
|
||||
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
||||
},
|
||||
fields: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}',
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
||||
},
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "version ถูกบันทึกแล้ว",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/UploadVersionResult" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||
413: {
|
||||
description: "ไฟล์แนบใหญ่เกิน 100MB",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
get: {
|
||||
tags: ["documents"],
|
||||
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
|
||||
description:
|
||||
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " +
|
||||
"ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " +
|
||||
"และ hasPdf — ใช้เขียน UI version history ของ add-in",
|
||||
security: [{ DocverBearer: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "รายการ version เรียงใหม่ → เก่า",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { $ref: "#/components/schemas/VersionRecord" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||
404: {
|
||||
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/versions/{versionId}/download-link": {
|
||||
get: {
|
||||
tags: ["documents"],
|
||||
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
|
||||
description:
|
||||
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " +
|
||||
"(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " +
|
||||
"ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " +
|
||||
"ใน version ที่ไม่มีสำเนา = 404",
|
||||
security: [{ DocverBearer: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: "versionId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", format: "uuid" },
|
||||
description: "id ของ version (field versionId ใน VersionRecord)",
|
||||
},
|
||||
{
|
||||
name: "format",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["docx", "pdf"], default: "docx" },
|
||||
description: "ชนิดไฟล์ที่จะดาวน์โหลด (ไม่ส่ง = docx)",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "presigned URL พร้อมเวลาหมดอายุ",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/DownloadLink" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||
404: {
|
||||
description:
|
||||
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
|
@ -225,7 +850,17 @@ export const openApiSpec = {
|
|||
in: "header",
|
||||
name: "x-api-key",
|
||||
description:
|
||||
"ค่าเดียวกับ env EXTERNAL_API_KEY ของ server — กดปุ่ม Authorize ด้านบนเพื่อใส่ครั้งเดียวแล้วใช้ทุก request",
|
||||
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
|
||||
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
|
||||
"request ของกลุ่มที่ key ตรง)",
|
||||
},
|
||||
DocverBearer: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
description:
|
||||
"access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " +
|
||||
"กับ endpoint อื่นของกลุ่ม documents ทุกตัว",
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
|
|
@ -239,6 +874,15 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
DocverUnauthorized: {
|
||||
description:
|
||||
"ไม่ได้แนบ Authorization: Bearer หรือ token ไม่ถูกต้อง/หมดอายุ (กลุ่ม documents — ข้อความเดียวกันทุกสาเหตุ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
ServerError: {
|
||||
description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)",
|
||||
content: {
|
||||
|
|
@ -252,21 +896,104 @@ export const openApiSpec = {
|
|||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean", example: false },
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
TokenRequest: {
|
||||
type: "object",
|
||||
description:
|
||||
"claims ที่จะฝังใน access_token (ทางเข้า x-api-key) — key อื่น = 400",
|
||||
required: ["userid", "username", "fid"],
|
||||
properties: {
|
||||
userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" },
|
||||
username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" },
|
||||
fid: {
|
||||
type: "string",
|
||||
description:
|
||||
"fileId ของ e-Court (บังคับ, ≤ 200 ตัวอักษร) — เป็น docId ของทุก operation ตรง ๆ",
|
||||
},
|
||||
template: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"รหัส template ที่ใช้สร้างเอกสาร — กลายเป็น templateId ตอนอัปโหลด version",
|
||||
},
|
||||
},
|
||||
},
|
||||
TicketTokenRequest: {
|
||||
type: "object",
|
||||
description:
|
||||
"ทางเข้าของ add-in (ไม่ต้องมี x-api-key) — ส่ง ticket ที่ e-Court stamp มากับ " +
|
||||
"ไฟล์ .docx ใน custom properties ตามเป๊ะ · key อื่น = 400 · server ตรวจ " +
|
||||
"HMAC-SHA256(ลับ, ticket_data ดิบ) เทียบ HmacSha256 + exp ของ ticket — " +
|
||||
"ไม่ผ่าน = 401 ข้อความเดียวกันทุกสาเหตุ",
|
||||
required: ["ticket_data", "HmacSha256"],
|
||||
properties: {
|
||||
ticket_data: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON string ของ ticket เช่น {"exp":1793067433,"fid":"…","tid":"…","uid":"…","uname":"…"} ' +
|
||||
"(exp = unix วินาที · tid optional · ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)",
|
||||
},
|
||||
HmacSha256: {
|
||||
type: "string",
|
||||
description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ)",
|
||||
},
|
||||
},
|
||||
},
|
||||
TokenResponse: {
|
||||
type: "object",
|
||||
description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)",
|
||||
properties: {
|
||||
access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" },
|
||||
token_type: { type: "string", example: "Bearer" },
|
||||
expires_in: {
|
||||
type: "integer",
|
||||
description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)",
|
||||
},
|
||||
},
|
||||
},
|
||||
ExtractResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean", example: true },
|
||||
data: {
|
||||
type: "object",
|
||||
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
|
||||
additionalProperties: { type: "string" },
|
||||
example: {
|
||||
blackno: "คดีหมายเลขดำที่",
|
||||
court: "ศาลปกครองสุพรรณบุรี",
|
||||
"cc": {
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "xxx",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง...",
|
||||
},
|
||||
"bookmark": {
|
||||
"signature": ""
|
||||
},
|
||||
"properties": {
|
||||
"standard": {
|
||||
"creator": "mam",
|
||||
"created": "2026-10-22T02:58:00.000Z",
|
||||
"modified": "2026-10-22T07:08:00.000Z"
|
||||
},
|
||||
"extended": {
|
||||
"template": "TS_P2_IN.dotx",
|
||||
"totalTime": 172,
|
||||
"pages": 2,
|
||||
"words": 124,
|
||||
},
|
||||
"custom": {
|
||||
"ticket_data": "{\"exp\":1793013726,\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}",
|
||||
"HmacSha256": "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
|
||||
"Ed25519": "95ea0ca6d81df885c22a2334ed89b2fc3c75a1a309fb5843e0deb0a16c9d05a45b345fb28fd606f3b152285e70560ad43a6389534794aa0d6f06d13f1307b005"
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"exp": "26/10/2569 18:22:06",
|
||||
"HmacSha256": true,
|
||||
"Ed25519": true
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -407,6 +1134,48 @@ export const openApiSpec = {
|
|||
createdAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
UploadVersionResult: {
|
||||
type: "object",
|
||||
description: "ตอบกลับหลังอัปโหลด version สำเร็จ",
|
||||
properties: {
|
||||
hasPdf: { type: "boolean", description: "true = version นี้มีสำเนา PDF" },
|
||||
versionId: { type: "string", format: "uuid" },
|
||||
versionNumber: {
|
||||
type: "integer",
|
||||
description: "เลข version ที่ server จ่าย (ค่าจริง — ไม่ใช่ที่ client คาด)",
|
||||
},
|
||||
},
|
||||
},
|
||||
VersionRecord: {
|
||||
type: "object",
|
||||
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
|
||||
properties: {
|
||||
versionId: { type: "string", format: "uuid" },
|
||||
versionNumber: { type: "integer" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
createdByUserId: { type: "string" },
|
||||
createdByUserName: { type: "string" },
|
||||
contentHash: {
|
||||
type: "string",
|
||||
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
|
||||
},
|
||||
fields: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
message: { type: "string", nullable: true, description: "commit message" },
|
||||
hasPdf: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
DownloadLink: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "presigned URL ของ MinIO (GET โดยตรง)" },
|
||||
expiresAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -23,13 +23,20 @@ export function middleware(req: NextRequest) {
|
|||
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
||||
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||
// ไฟล์อัปโหลด, Next internal, และ static ใน public/note/ (pdf.worker, samples,
|
||||
// asset Swagger UI) + public/ ราก (fonts, favicon)
|
||||
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
|
||||
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
|
||||
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
|
||||
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
|
||||
// + public/ ราก (fonts, favicon)
|
||||
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
||||
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
||||
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
|
||||
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
|
||||
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!note/login|api/note|api/template|api/swagger|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
|
||||
"/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,10 +14,24 @@ const nextConfig = {
|
|||
basePath,
|
||||
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
|
||||
env: { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_APP_PATH: appPath },
|
||||
// เข้า /docedit/ ตรง ๆ (ไม่มี route ที่ root) → โผล่ที่หน้ารายการของ /note
|
||||
// (Next เติม basePath ให้ source/destination เอง + query ส่งต่ออัตโนมัติ — รองรับ /docedit/?fileUrl=…)
|
||||
// ปิด redirect trailing slash อัตโนมัติของ Next (308 /docedit → /docedit/
|
||||
// และ /docedit/note/ → /docedit/note) — deploy หลัง IIS แล้ว redirect พวกนี้
|
||||
// ตีกับ rewrite rule ของ IIS จนกลายเป็น ping-pong loop; ปิดให้หน้า/ไฟล์ทุกอย่าง
|
||||
// serve ได้ทั้งแบบมี/ไม่มี slash ฝั่ง proxy เป็นคนกำหนดเอง
|
||||
skipTrailingSlashRedirect: true,
|
||||
// จับเฉพาะ root ของ host ("/" แท้ ๆ — basePath:false ทำให้ rule นี้ไม่จับ
|
||||
// /docedit) เผื่อ IIS ส่ง path "/" เข้า process: ส่งตรงไป /docedit/note เลย
|
||||
// ส่วน /docedit และ /docedit/ จับที่ app/page.tsx แทน (ส่งต่อ query เอง —
|
||||
// รักษาสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId=)
|
||||
async redirects() {
|
||||
return [{ source: "/", destination: appPath, permanent: false }];
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: `${basePath}${appPath}`,
|
||||
basePath: false, // ป้องกัน basePath ซ้อน (destination เขียนเต็มแล้ว)
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
||||
experimental: {
|
||||
|
|
|
|||
8337
package-lock.json
generated
8337
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -8,13 +8,17 @@
|
|||
"test": "vitest run",
|
||||
"start": "next start",
|
||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs"
|
||||
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
|
||||
"dev:https": "next dev --experimental-https",
|
||||
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"docx-templates": "^4.15.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "^14.2.35",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
|
|
@ -28,13 +32,16 @@
|
|||
"xpath": "^0.0.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/office-js": "^1.0.612",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"office-addin-debugging": "^7.0.1",
|
||||
"office-addin-dev-settings": "^4.0.1",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
7543
pnpm-lock.yaml
generated
Normal file
7543
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
allowBuilds:
|
||||
'@azure/msal-node-extensions': set this to true or false
|
||||
'@microsoft/m365agentstoolkit-cli': set this to true or false
|
||||
'@scarf/scarf': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
keytar: set this to true or false
|
||||
44
public/tmpl/T12_FD155_3s-long.json
Normal file
44
public/tmpl/T12_FD155_3s-long.json
Normal file
File diff suppressed because one or more lines are too long
BIN
app/api/template/test/T3.docx → public/tmpl/T12_X.docx
Normal file → Executable file
BIN
app/api/template/test/T3.docx → public/tmpl/T12_X.docx
Normal file → Executable file
Binary file not shown.
41
public/tmpl/T12_b2_IN.json
Normal file
41
public/tmpl/T12_b2_IN.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"info": {
|
||||
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||
},
|
||||
"dc": {
|
||||
"Interpleader": true,
|
||||
"docname": "",
|
||||
"docabbr": "(ต. ๑๒)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
"court": "ศาลปกครองนครราชสีมา",
|
||||
"panels": [
|
||||
{
|
||||
"pname": "นายวิรุฬห์ จินตนะกุล",
|
||||
"pmname": "ตุลาการศาลปกครองกลาง",
|
||||
"pm": "ตุลาการเจ้าของสำนวน"
|
||||
},
|
||||
{
|
||||
"pname": "นายสัมฤทธิ์ อ่อนคำ",
|
||||
"pmname": "ตุลาการศาลปกครองสูงสุด"
|
||||
}
|
||||
],
|
||||
"pnameT":"",
|
||||
"pmnameT":"",
|
||||
"pmT":"",
|
||||
"acc_name":""
|
||||
|
||||
},
|
||||
"cc": {
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "รร",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"interpleader": "สมชาย ใจดี",
|
||||
"acc3": "ผู้ร้องสอด",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
|
||||
}
|
||||
}
|
||||
50
public/tmpl/TF_no_warning_P2.json
Executable file
50
public/tmpl/TF_no_warning_P2.json
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"info": {
|
||||
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||
},
|
||||
"dc": {
|
||||
"Interpleader": false,
|
||||
"CommandTo": true,
|
||||
"SendTo": false,
|
||||
"AdditionCommand" : true,
|
||||
"Warning":false,
|
||||
|
||||
"docname":"คำสั่งศาล",
|
||||
"doctype":"(ต. ๔๗.๑)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
|
||||
"court": "ศาลปกครองนครราชสีมา",
|
||||
"doctitle":"คำสั่งศาลถึง",
|
||||
"pname":"สมหญิง",
|
||||
|
||||
"courtname":"ศาลปกครองสุพรรณบุรี",
|
||||
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
|
||||
"pname2":"ชื่อของ pname2"
|
||||
},
|
||||
"cc": {
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "xxx",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||
"commto" : "นายสมชาย จรดปลายเท้า",
|
||||
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
|
||||
"groupname":"ผู้อำนวยการโรงเรียน",
|
||||
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "รร",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"interpleader": "สมชาย ใจดี",
|
||||
"acc3": "ผู้ร้องสอด",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||
|
||||
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
|
||||
"remarkhead":"หัวของ Remark",
|
||||
"remark":"This is remark Text bla bla "
|
||||
}
|
||||
}
|
||||
52
public/tmpl/TS_P2_IN.json
Executable file
52
public/tmpl/TS_P2_IN.json
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"info": {
|
||||
"ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||
},
|
||||
"dc": {
|
||||
"Interpleader": true,
|
||||
"CommandTo": true,
|
||||
"SendTo": false,
|
||||
"AdditionCommand" : true,
|
||||
"Warning":true,
|
||||
|
||||
"docname":"คำสั่งเรียกพยานบุคคล",
|
||||
"doctype":"(ต. ๖)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
|
||||
"court": "ศาลปกครองนครราชสีมา",
|
||||
"doctitle":"คำสั่งถึง",
|
||||
"pname":"สมหญิง",
|
||||
|
||||
"courtname":"ศาลปกครองสุพรรณบุรี",
|
||||
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
|
||||
|
||||
"pname2":"ชื่อของ pname2",
|
||||
"remarkhead":"หัวของ Remark"
|
||||
},
|
||||
"cc": {
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "xxx",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||
"commto" : "นายสมชาย จรดปลายเท้า",
|
||||
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
|
||||
"groupname":"ผู้อำนวยการโรงเรียน",
|
||||
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "รร",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"interpleader": "สมชาย ใจดี",
|
||||
"acc3": "ผู้ร้องสอด",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||
|
||||
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
|
||||
|
||||
"remark":"This is remark Text bla bla "
|
||||
}
|
||||
}
|
||||
BIN
app/api/template/test/output-T3.docx → public/tmpl/T_X.docx
Normal file → Executable file
BIN
app/api/template/test/output-T3.docx → public/tmpl/T_X.docx
Normal file → Executable file
Binary file not shown.
51
public/tmpl/T_no_warning.json
Executable file
51
public/tmpl/T_no_warning.json
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"info": {
|
||||
"ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||
},
|
||||
"dc": {
|
||||
"Interpleader": false,
|
||||
"CommandTo": true,
|
||||
"SendTo": true,
|
||||
"AdditionCommand" : false,
|
||||
"Warning":false,
|
||||
|
||||
"docname":"คำสั่งเรียก\nให้ทำคำให้การเพิ่มเติม",
|
||||
"doctype":"(ต. ๓)",
|
||||
"blackno": "คดีหมายเลขดำที่",
|
||||
"redno": "คดีหมายเลขแดงที่",
|
||||
|
||||
"court": "ศาลปกครองนครราชสีมา",
|
||||
"doctitle":"คำสั่งถึง",
|
||||
"pname":"สมหญิง",
|
||||
|
||||
"courtname":"ศาลปกครองสุพรรณบุรี",
|
||||
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
|
||||
"pname2":"ชื่อของ pname2"
|
||||
},
|
||||
"cc": {
|
||||
"docname":"คำสั่งเรียกพยานบุคคล",
|
||||
"blackyear": "๙๐/๒๕๖๙",
|
||||
"redyear": "xxx",
|
||||
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||
"commto" : "นายสมชาย จรดปลายเท้า",
|
||||
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
|
||||
"groupname":"ผู้อำนวยการโรงเรียน",
|
||||
|
||||
"accuser": "นายธีรพล ร่มเริง",
|
||||
"acc1": "ผู้ฟ้องคดี",
|
||||
"accused": "รร",
|
||||
"acc2": "ผู้ถูกฟ้องคดี",
|
||||
"interpleader": "สมชาย ใจดี",
|
||||
"acc3": "ผู้ร้องสอด",
|
||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||
|
||||
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
|
||||
"remarkhead":"หัวของ Remark",
|
||||
"remark":"This is remark Text bla bla "
|
||||
}
|
||||
}
|
||||
BIN
public/wordaddin/icons/icon-16.png
Normal file
BIN
public/wordaddin/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/wordaddin/icons/icon-32.png
Normal file
BIN
public/wordaddin/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/wordaddin/icons/icon-64.png
Normal file
BIN
public/wordaddin/icons/icon-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/wordaddin/icons/icon-80.png
Normal file
BIN
public/wordaddin/icons/icon-80.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
118
public/wordaddin/manifest.template.xml
Normal file
118
public/wordaddin/manifest.template.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
manifest ของ Word add-in "e-Court Documents Versioning" (พอร์ตจาก D:\word-addin-poc\addin\manifest.xml)
|
||||
|
||||
ไฟล์นี้เป็น **template** — URL ทุกตัวเป็น absolute https ตามข้อบังคับของ Office
|
||||
โดยค่า default คือ dev (https://localhost:3000/docedit/wordaddin) และถูก serve
|
||||
ผ่าน route `app/wordaddin/manifest.xml/route.ts` ที่ rewrite origin ให้ตรงกับ
|
||||
โฮสต์ที่ขอไฟล์อัตโนมัติ (dev/UAT/production ใช้ template เดียวกัน — เข้าถึงที่
|
||||
/docedit/wordaddin/manifest.xml ของ environment นั้น ๆ ได้เลย)
|
||||
(รายละเอียดวิธีรัน dev + sideload: docs/WORDADDIN-DEV.md)
|
||||
|
||||
ผลต่างจากต้นฉบับ:
|
||||
- taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack
|
||||
- ตัด DialogAPI + AppDomain ของ Keycloak (https://localhost:8443) — ยกเลิก auth ด้วย Keycloak
|
||||
2026-10-27: ไม่มี login ใน add-in auth เป็น e-Court pass-through (token/owner ใน custom
|
||||
properties ของไฟล์) requirement set เหลือ WordApi 1.3 + OpenBrowserWindowApi
|
||||
(ดูรายละเอียดระดับที่ใช้จริงในคอมเมนต์ก่อน <Requirements>)
|
||||
- Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane)
|
||||
- คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม
|
||||
-->
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
|
||||
<Version>1.0.0.2</Version>
|
||||
<ProviderName>e-Court Documents Versioning</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||
<Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-64.png"/>
|
||||
<SupportUrl DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://localhost:3000</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Document"/>
|
||||
</Hosts>
|
||||
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
|
||||
WordApi 1.3: API สูงสุดที่ใช้นอกเหนือจาก close() — customXmlParts (1.3), customProperties /
|
||||
document.save() / contentControls (1.1) — และเป็นระดับสูงสุดที่ Office 2019 perpetual
|
||||
รองรับ (WordApi 1.4/1.5 = Microsoft 365 เท่านั้น — เดิมตั้ง 1.5 ทำให้เครื่องศาลที่ใช้
|
||||
Office 2019 โหลด add-in ไม่ขึ้นเลย 2026-10-16); document.close() (FR-18) ตรวจ runtime
|
||||
แยกที่ canCloseDocument() → isSetSupported("WordApi","1.5") — เครื่องที่ไม่ถึงซ่อนปุ่ม
|
||||
ปิดเอกสารทุกแบบ (พฤติกรรมเดียวกับ Word on the web)
|
||||
OpenBrowserWindowApi 1.1: เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
|
||||
<Requirements>
|
||||
<Sets DefaultMinVersion="1.1">
|
||||
<Set Name="WordApi" MinVersion="1.3"/>
|
||||
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<DefaultSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||
</DefaultSettings>
|
||||
<Permissions>ReadWriteDocument</Permissions>
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||
<Hosts>
|
||||
<Host xsi:type="Document">
|
||||
<DesktopFormFactor>
|
||||
<GetStarted>
|
||||
<Title resid="GetStarted.Title"/>
|
||||
<Description resid="GetStarted.Description"/>
|
||||
<LearnMoreUrl resid="GetStarted.LearnMoreUrl"/>
|
||||
</GetStarted>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
<ExtensionPoint xsi:type="PrimaryCommandSurface">
|
||||
<OfficeTab id="TabHome">
|
||||
<Group id="CommandsGroup">
|
||||
<Label resid="CommandsGroup.Label"/>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Control xsi:type="Button" id="TaskpaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<TaskpaneId>ButtonId1</TaskpaneId>
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="GetStarted.LearnMoreUrl" DefaultValue="https://go.microsoft.com/fwlink/?LinkId=276812"/>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GetStarted.Title" DefaultValue="Get started with your add-in!"/>
|
||||
<bt:String id="CommandsGroup.Label" DefaultValue="e-Court Add-in"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="e-Court Documents Versioning"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="GetStarted.Description" DefaultValue="Your add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
153
scripts/fetch-office-js.mjs
Normal file
153
scripts/fetch-office-js.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// ดาวน์โหลด Office.js runtime "ฉบับล่าสุด" จาก CDN อย่างเป็นทางการ
|
||||
// (appsforoffice.microsoft.com/lib/1/hosted) มา self-host ที่ public/wordaddin/office-js/
|
||||
// ให้ taskpane ของ wordaddin โหลดแบบ same-origin ได้ (/docedit/wordaddin/office-js/office.js)
|
||||
// — ระบบ deploy ใช้ใน intranet จึง self-host (CDN ใช้ไม่ได้ในเครือข่ายศาล) ส่วนตัว
|
||||
// ดาวน์โหลดเองตอน npm/pnpm install บนเครื่องที่มีอินเทอร์เน็ต เหมือนดึง dependency อื่น ๆ
|
||||
//
|
||||
// ทำไมไม่ใช้แพ็กเกจ @microsoft/office-js แบบเดิม: npm หยุดอัปเดตที่ 1.1.110 (~2018)
|
||||
// ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ที่ FR-18 ต้องใช้ (เรียกแล้ว
|
||||
// ได้ "context.document.close is not a function") และไม่มี enum Word.CloseBehavior /
|
||||
// Word.SaveBehavior ทับ ๆ กับ @types/office-js ฉบับใหม่ — จึงต้องเป็นฉบับ CDN ล่าสุดที่ทัน
|
||||
// API ปัจจุบัน (rolling — ได้ตัวใหม่ทุกครั้งที่ติดตั้ง และตรงกับ @types ที่ compile ด้วย)
|
||||
//
|
||||
// ชุดไฟล์ = **เฉพาะของ Word** ตามที่ loader อ้างถึง: ไฟล์ host ราย platform จากตาราง
|
||||
// เวอร์ชันใน office.js (word: {ios:16.00, mac:16.00, web:16.00, win32:16.01}) + ไฟล์
|
||||
// legacy 15.x กัน Word รุ่นเก่าในองค์กร + o15apptofilemappingtable/microsoftajax ที่
|
||||
// loader ดึง + office_strings.js ต่อ locale — ไฟล์หลักหายตัวไหน (CDN เปลี่ยนชื่อ/ลบ) =
|
||||
// exit 1 ดัง ๆ ทันที ไม่ปล่อยให้ taskpane พังเงียบ ๆ (pattern เดียวกับ copy-swagger-ui.mjs)
|
||||
// ยกเว้น office_strings.js ของ locale รอง — หายเป็น warning (loader fallback เป็น en-us)
|
||||
//
|
||||
// หลังดาวน์โหลดตรวจ API surface ของ word-win32-16.01.js อีกชั้น: ต้องมี "Document.close"
|
||||
// และ CloseBehavior — วันหนึ่งถ้า CDN ถอด API ที่ add-in ใช้ ให้ติดตั้งพังตั้งแต่ต้น
|
||||
// ไม่ใช่มาพังตอนผู้ใช้กดปุ่มใน Word
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const CDN = "https://appsforoffice.microsoft.com/lib/1/hosted";
|
||||
const DEST = "public/wordaddin/office-js";
|
||||
|
||||
/** ไฟล์หลักที่ loader ดึงตอนรันจริง — หายตัวไหน = พังทั้ง taskpane */
|
||||
const REQUIRED_FILES = [
|
||||
"office.js",
|
||||
"o15apptofilemappingtable.js",
|
||||
"microsoftajax.js",
|
||||
// host files ราย platform (ตารางเวอร์ชันของ loader) + legacy 15.x
|
||||
"word-win32-16.01.js",
|
||||
"word-web-16.00.js",
|
||||
"word-mac-16.00.js",
|
||||
"word-ios-16.00.js",
|
||||
"word-15.js",
|
||||
"word-15.01.js",
|
||||
"word-15.02.js",
|
||||
"wordios-15.js",
|
||||
];
|
||||
|
||||
/** locale ที่หายไม่ได้ (ผู้ใช้หลักของระบบ) — locale อื่นหายเป็น warning */
|
||||
const REQUIRED_LOCALES = ["en-us", "th-th"];
|
||||
|
||||
/** locale ที่ CDN มี office_strings.js จริง (snapshot 2026-10 — Microsoft รวม locale รอง
|
||||
* ให้ fallback ไป en-us แล้ว เหลือ ~64 ภาษาหลัก) — locale อื่น ๆ ของผู้ใช้จะได้ข้อความ
|
||||
* อังกฤษ ไม่ใช่ taskpane พัง */
|
||||
const LOCALES = [
|
||||
"am-et", "ar-sa", "bg-bg", "bn-in", "ca-es", "cs-cz", "cy-gb", "da-dk", "de-de", "el-gr",
|
||||
"en-us", "es-es", "es-mx", "et-ee", "eu-es", "fa-ir", "fi-fi", "fil-ph", "fr-ca", "fr-fr",
|
||||
"gl-es", "gu-in", "he-il", "hi-in", "hr-hr", "hu-hu", "id-id", "is-is", "it-it", "ja-jp",
|
||||
"kk-kz", "kn-in", "ko-kr", "lo-la", "lt-lt", "lv-lv", "ml-in", "mr-in", "ms-my", "nb-no",
|
||||
"nl-nl", "nn-no", "pl-pl", "pt-br", "pt-pt", "ro-ro", "ru-ru", "sk-sk", "sl-si", "sr-cyrl-cs",
|
||||
"sr-cyrl-rs", "sr-latn-cs", "sr-latn-rs", "sv-se", "sw-ke", "ta-in", "te-in", "th-th", "tr-tr",
|
||||
"uk-ua", "ur-pk", "vi-vn", "zh-cn", "zh-tw",
|
||||
];
|
||||
|
||||
/** ดึงไฟล์จาก CDN แบบ retry — คืน null เมื่อล้มทุกรอบ (เรียกต่อไปตัดสิน fatal/warning เอง) */
|
||||
async function fetchText(path, retries = 2) {
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${CDN}/${path}`, { redirect: "follow" });
|
||||
if (response.ok) {
|
||||
return { text: await response.text(), lastModified: response.headers.get("last-modified") };
|
||||
}
|
||||
if (response.status === 404) return null; // ไม่มีจริง — retry ไม่ช่วย
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
if (attempt === retries) {
|
||||
console.error(`[fetch-office-js] ดึง ${path} ไม่สำเร็จหลัง retry: ${error?.message ?? error}`);
|
||||
return null;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function save(path, text) {
|
||||
const target = join(DEST, path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, text);
|
||||
}
|
||||
|
||||
// ลบโฟลเดอร์เดิมเป็น best-effort — บน Windows ตอน next dev เปิดค้าง chokidar อาจจับ
|
||||
// handle โฟลเดอร์ public/ ไว้ (EPERM แม่น ๆ) ลบไม่ได้ก็เขียนทับในที่เดิมได้ (ไฟล์ที่ CDN
|
||||
// เลิกใช้จะตกค้างเงียบ ๆ ใน dev แต่ fresh install/Docker ได้โฟลเดอร์สะอาดตามปกติ)
|
||||
try {
|
||||
rmSync(DEST, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
|
||||
} catch {
|
||||
console.warn("[fetch-office-js] ลบโฟลเดอร์เดิมไม่ได้ (process อื่นจับ handle อยู่ เช่น dev server) — เขียนทับในที่เดิม");
|
||||
}
|
||||
mkdirSync(DEST, { recursive: true });
|
||||
|
||||
// --- ไฟล์หลัก: ทีละตัว (ไฟล์ใหญ่) ตัวไหนหาย = fatal ---
|
||||
let officeLastModified;
|
||||
for (const file of REQUIRED_FILES) {
|
||||
const result = await fetchText(file);
|
||||
if (!result) {
|
||||
console.error(
|
||||
`[fetch-office-js] ไม่ได้ไฟล์หลัก ${file} — CDN เปลี่ยนชื่อ/ลบไฟล์รึเปล่า ` +
|
||||
`(ปรับ REQUIRED_FILES ใน scripts/fetch-office-js.mjs) หรือเครือข่ายสู่ appsforoffice.microsoft.com ไม่ผ่าน`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
save(file, result.text);
|
||||
if (file === "office.js") officeLastModified = result.lastModified;
|
||||
}
|
||||
|
||||
// --- locale strings: ยิงกลุ่มละ 8 ไฟล์ (ไฟล์เล็ก) — รองหาย = warning ---
|
||||
const missingLocales = [];
|
||||
for (let i = 0; i < LOCALES.length; i += 8) {
|
||||
const batch = LOCALES.slice(i, i + 8);
|
||||
await Promise.all(
|
||||
batch.map(async (locale) => {
|
||||
const result = await fetchText(`${locale}/office_strings.js`);
|
||||
if (result) {
|
||||
save(`${locale}/office_strings.js`, result.text);
|
||||
} else {
|
||||
missingLocales.push(locale);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (missingLocales.length > 0) {
|
||||
console.warn(`[fetch-office-js] office_strings.js หาย ${missingLocales.length} locale (loader จะ fallback en-us): ${missingLocales.join(", ")}`);
|
||||
}
|
||||
const missingRequiredLocales = REQUIRED_LOCALES.filter((locale) => missingLocales.includes(locale));
|
||||
if (missingRequiredLocales.length > 0) {
|
||||
console.error(`[fetch-office-js] หาย office_strings.js ของ locale หลัก: ${missingRequiredLocales.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- ตรวจ API surface ที่ add-in ใช้จริง (กัน CDN regression หลุดเงียบ) ---
|
||||
const win32 = readFileSync(join(DEST, "word-win32-16.01.js"), "utf8");
|
||||
const missingApis = [];
|
||||
if (!win32.includes('"Document.close"')) missingApis.push('"Document.close" (FR-18 ปิดเอกสาร)');
|
||||
if (!win32.includes("CloseBehavior")) missingApis.push("CloseBehavior enum");
|
||||
if (missingApis.length > 0) {
|
||||
console.error(
|
||||
`[fetch-office-js] word-win32-16.01.js จาก CDN ไม่มี API: ${missingApis.join(", ")} — ` +
|
||||
`อย่า deploy ตัวนี้ (ปุ่มปิดเอกสารจะพังใน Word) แจ้งทีมแล้วตรวจ scripts/fetch-office-js.mjs`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[fetch-office-js] ดาวน์โหลด Office.js runtime ล่าสุดเรียบร้อย ` +
|
||||
`(office.js ของวันที่ ${officeLastModified ?? "ไม่ทราบ"} — ${REQUIRED_FILES.length} ไฟล์หลัก + ${LOCALES.length - missingLocales.length} locale)`,
|
||||
);
|
||||
263
scripts/make-ticket-docx.mjs
Normal file
263
scripts/make-ticket-docx.mjs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env node
|
||||
// PLAN-ADDIN-AUTH.md §9 — dev tool stamp ticket ของ e-Court (custom properties
|
||||
// `ticket_data` + `HmacSha256`) ลงไฟล์ .docx เพื่อทดสอบ auth ของ add-in ใน Word จริง
|
||||
// โดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง
|
||||
// (app/api/template/render/route.ts: key เรียงตามตัวอักษรหลังเติม exp →
|
||||
// HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase)
|
||||
//
|
||||
// ใช้: node scripts/make-ticket-docx.mjs <file.docx> [options]
|
||||
// --fid <id> default DEMO_FID_001
|
||||
// --uid <id> default oom
|
||||
// --uname <name> default "สรวิชญ์"
|
||||
// --tid <id> template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket)
|
||||
// --exp <unix|+sec> default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp)
|
||||
// --secret <secret> default "super_secret" (ต้องตรง env ฝั่ง server/add-in)
|
||||
// --tracked [id] stamp SavedByAddIn + DocumentTrackingId ด้วย (ทดสอบ phase tracking)
|
||||
// --in-place เขียนทับไฟล์เดิม (ปกติเขียน <ชื่อเดิม>.ticket.docx)
|
||||
// --list แค่อ่าน custom properties ที่มีในไฟล์แล้วออกมาดู
|
||||
//
|
||||
// รันใน repo เท่านั้น (import pizzip จาก node_modules) — ไม่ใช่ postinstall
|
||||
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import PizZip from "pizzip";
|
||||
|
||||
const CUSTOM_PATH = "docProps/custom.xml";
|
||||
const CT_PATH = "[Content_Types].xml";
|
||||
const RELS_PATH = "_rels/.rels";
|
||||
const CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
|
||||
/** ต้องตรง metadataService.ADDIN_SIGNATURE_GUID ฝั่ง add-in */
|
||||
const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||
|
||||
function usage(code = 1) {
|
||||
console.log(`ใช้: node scripts/make-ticket-docx.mjs <file.docx> [options]
|
||||
--fid <id> --uid <id> --uname <name> --tid <id>
|
||||
--exp <unix|+seconds> --secret <secret>
|
||||
--tracked [trackingId] --in-place --list`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
file: null,
|
||||
fid: "DEMO_FID_001",
|
||||
uid: "oom",
|
||||
uname: "สรวิชญ์",
|
||||
tid: null,
|
||||
exp: "+300",
|
||||
secret: "super_secret",
|
||||
tracked: false,
|
||||
trackingId: null,
|
||||
inPlace: false,
|
||||
list: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = () => {
|
||||
const value = argv[++i];
|
||||
if (value === undefined) usage();
|
||||
return value;
|
||||
};
|
||||
switch (arg) {
|
||||
case "--fid": options.fid = next(); break;
|
||||
case "--uid": options.uid = next(); break;
|
||||
case "--uname": options.uname = next(); break;
|
||||
case "--tid": options.tid = next(); break;
|
||||
case "--exp": options.exp = next(); break;
|
||||
case "--secret": options.secret = next(); break;
|
||||
case "--tracked":
|
||||
options.tracked = true;
|
||||
if (argv[i + 1] && !argv[i + 1].startsWith("--")) options.trackingId = next();
|
||||
break;
|
||||
case "--in-place": options.inPlace = true; break;
|
||||
case "--list": options.list = true; break;
|
||||
case "--help": case "-h": usage(0); break;
|
||||
default:
|
||||
if (arg.startsWith("--")) usage();
|
||||
if (options.file !== null) usage();
|
||||
options.file = arg;
|
||||
}
|
||||
}
|
||||
if (options.file === null) usage();
|
||||
return options;
|
||||
}
|
||||
|
||||
function resolveExp(raw) {
|
||||
if (raw.startsWith("+")) {
|
||||
const seconds = Number(raw.slice(1));
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--exp +<วินาที> ไม่ถูกต้อง");
|
||||
return Math.floor(Date.now() / 1000) + Math.floor(seconds);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--exp ต้องเป็น unix วินาที หรือ +<วินาที>");
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** ticket_data ตามรูปตัว stamp จริง — key เรียงตามตัวอักษรหลังเติม exp */
|
||||
function buildTicketData(options) {
|
||||
const ticket = { exp: resolveExp(options.exp), fid: options.fid, uid: options.uid, uname: options.uname };
|
||||
if (options.tid !== null) ticket.tid = options.tid;
|
||||
const sorted = Object.keys(ticket)
|
||||
.sort()
|
||||
.reduce((result, key) => {
|
||||
result[key] = ticket[key];
|
||||
return result;
|
||||
}, {});
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
function unescapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/** ถอด <Property name value> ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */
|
||||
function readProperties(xml) {
|
||||
const properties = [];
|
||||
const regex = /<Property\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|<Property\s+[^>]*name="([^"]*)"[^>]*\/>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
const name = match[1] ?? match[3];
|
||||
const inner = match[2] ?? "";
|
||||
const value = inner.replace(/<[^>]+>/g, "");
|
||||
properties.push({ name: unescapeXml(name), value: unescapeXml(value.trim()) });
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
function usedPids(xml) {
|
||||
const pids = new Set([1]);
|
||||
const regex = /pid="(\d+)"/g;
|
||||
let match;
|
||||
while ((match = regex.exec(xml)) !== null) pids.add(Number(match[1]));
|
||||
return pids;
|
||||
}
|
||||
|
||||
function propertyXml(name, pid, value) {
|
||||
return ` <Property fmtid="${CUSTOM_FMTID}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></Property>\n`;
|
||||
}
|
||||
|
||||
/** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */
|
||||
function upsertProperties(existingXml, entries) {
|
||||
// เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง)
|
||||
let xml = existingXml;
|
||||
for (const entry of entries) {
|
||||
const removal = new RegExp(
|
||||
`\\s*<Property\\s+[^>]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)</Property>`,
|
||||
"g",
|
||||
);
|
||||
xml = xml.replace(removal, "");
|
||||
}
|
||||
const pids = usedPids(xml);
|
||||
let nextPid = 2;
|
||||
const takePid = () => {
|
||||
while (pids.has(nextPid)) nextPid++;
|
||||
pids.add(nextPid);
|
||||
return nextPid++;
|
||||
};
|
||||
const block = entries.map((entry) => propertyXml(entry.name, takePid(), entry.value)).join("");
|
||||
const hasRoot = xml.includes("</Properties>");
|
||||
if (hasRoot) {
|
||||
xml = xml.replace(/<\/Properties>/, `${block}</Properties>`);
|
||||
} else {
|
||||
xml =
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">\n${block}</Properties>`;
|
||||
}
|
||||
return xml;
|
||||
}
|
||||
|
||||
/** ผูก docProps/custom.xml เข้ากับแพ็กเกจ — Word ไม่เห็น part ที่ไม่มี relationship/content-type */
|
||||
function ensurePartRegistered(zip) {
|
||||
const ct = zip.file(CT_PATH);
|
||||
if (ct && !ct.asText().includes('PartName="/docProps/custom.xml"')) {
|
||||
const updated = ct
|
||||
.asText()
|
||||
.replace(
|
||||
/<\/Types>/,
|
||||
` <Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.document-properties.custom+xml"/>\n</Types>`,
|
||||
);
|
||||
zip.file(CT_PATH, updated);
|
||||
}
|
||||
const rels = zip.file(RELS_PATH);
|
||||
if (rels && !rels.asText().includes('Target="docProps/custom.xml"')) {
|
||||
const match = rels.asText().match(/Id="rId(\d+)"/g);
|
||||
const maxId = match ? Math.max(...match.map((item) => Number(item.match(/\d+/)[0]))) : 0;
|
||||
const updated = rels
|
||||
.asText()
|
||||
.replace(
|
||||
/<\/Relationships>/,
|
||||
` <Relationship Id="rId${maxId + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>\n</Relationships>`,
|
||||
);
|
||||
zip.file(RELS_PATH, updated);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const source = readFileSync(options.file);
|
||||
|
||||
if (source.subarray(0, 2).toString("latin1") !== "PK") {
|
||||
console.error(`✗ ${options.file} ไม่ใช่ไฟล์ .docx (zip)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const zip = new PizZip(source);
|
||||
|
||||
if (options.list) {
|
||||
const custom = zip.file(CUSTOM_PATH);
|
||||
if (!custom) {
|
||||
console.log(`(ไม่มี ${CUSTOM_PATH} ในไฟล์นี้)`);
|
||||
} else {
|
||||
for (const property of readProperties(custom.asText())) {
|
||||
console.log(`${property.name} = ${property.value}`);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ticketData = buildTicketData(options);
|
||||
const hmac = createHmac("sha256", options.secret).update(ticketData, "utf8").digest("hex");
|
||||
if (ticketData.length > 255) {
|
||||
console.warn(`⚠ ticket_data ยาว ${ticketData.length} ตัวอักษร — เกินขีดจำกัด ~255 ของ Word custom property อาจถูกตัด`);
|
||||
}
|
||||
|
||||
const entries = [
|
||||
{ name: "ticket_data", value: ticketData },
|
||||
{ name: "HmacSha256", value: hmac },
|
||||
];
|
||||
if (options.tracked) {
|
||||
entries.push({ name: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID });
|
||||
entries.push({ name: "DocumentTrackingId", value: options.trackingId ?? randomUUID() });
|
||||
}
|
||||
|
||||
const existing = zip.file(CUSTOM_PATH);
|
||||
zip.file(CUSTOM_PATH, upsertProperties(existing ? existing.asText() : "", entries));
|
||||
ensurePartRegistered(zip);
|
||||
|
||||
const output = zip.generate({ type: "nodebuffer", compression: "DEFLATE" });
|
||||
const target = options.inPlace
|
||||
? options.file
|
||||
: `${options.file.replace(/\.docx$/i, "")}.ticket.docx`;
|
||||
writeFileSync(target, output);
|
||||
|
||||
console.log(`✓ stamp ticket ลง ${path.relative(process.cwd(), target)}${options.inPlace ? " (ทับไฟล์เดิม)" : ""}`);
|
||||
console.log(` ticket_data : ${ticketData}`);
|
||||
console.log(` HmacSha256 : ${hmac}`);
|
||||
console.log(` secret : ${options.secret}`);
|
||||
console.log(` exp : ${new Date(resolveExp(options.exp) * 1000).toLocaleString("th-TH")}`);
|
||||
148
tests/lib/addin-ticket.test.ts
Normal file
148
tests/lib/addin-ticket.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
addinTicketSecret,
|
||||
parseTicketTokenBody,
|
||||
ticketToDocverClaims,
|
||||
verifyAddinTicket,
|
||||
} from "@/lib/addin-ticket";
|
||||
|
||||
// PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter)
|
||||
// ไม่แตะ @/db/minio — รันได้โดยไม่ต้องตั้ง env อะไร (เหมือน docver-token.test.ts)
|
||||
|
||||
const SECRET = "super_secret";
|
||||
|
||||
/** ตัว ticket ตัวอย่าง — key เรียงตามตัวอักษรเหมือนตัว stamp จริง (sortObjectKeys ของ lib-utils) */
|
||||
const TICKET_DATA = JSON.stringify({
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
fid: "T12_FD155_3s-long_ZZZ",
|
||||
tid: "T12",
|
||||
uid: "oom",
|
||||
uname: "สรวิชญ์",
|
||||
});
|
||||
|
||||
function sign(message: string, secret: string = SECRET): string {
|
||||
return createHmac("sha256", secret).update(message, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
||||
describe("verifyAddinTicket", () => {
|
||||
it("ticket ถูกต้อง + HMAC ตรง = ผ่าน ได้ ticket แกะแล้ว", () => {
|
||||
const result = verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA), SECRET);
|
||||
ok(result);
|
||||
expect(result.ticket.fid).toBe("T12_FD155_3s-long_ZZZ");
|
||||
expect(result.ticket.uid).toBe("oom");
|
||||
expect(result.ticket.tid).toBe("T12");
|
||||
});
|
||||
|
||||
it("HMAC ผิด (แก้ ticket 1 ตัวอักษร / secret ผิด / digest เพี้ยน) = ไม่ผ่านทุกกรณี", () => {
|
||||
const digest = sign(TICKET_DATA);
|
||||
const tampered = `${TICKET_DATA.slice(0, -2)}xx}`;
|
||||
expect(verifyAddinTicket(tampered, digest, SECRET).ok).toBe(false);
|
||||
expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false);
|
||||
const flipped = `${digest.slice(0, -1)}${digest.endsWith("0") ? "1" : "0"}`;
|
||||
expect(verifyAddinTicket(TICKET_DATA, flipped, SECRET).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("digest รูปไม่ใช่ (ไม่ใช่ hex / สั้น / ว่าง) = ไม่ผ่าน · uppercase hex normalize แล้วผ่านได้", () => {
|
||||
expect(verifyAddinTicket(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false);
|
||||
expect(verifyAddinTicket(TICKET_DATA, "abcd", SECRET).ok).toBe(false);
|
||||
expect(verifyAddinTicket(TICKET_DATA, "", SECRET).ok).toBe(false);
|
||||
expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA).toUpperCase(), SECRET).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ticket หมดอายุ (exp อดีต / เท่ากับ now) = ไม่ผ่าน แม้ HMAC ถูก", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const exp of [now - 1, now]) {
|
||||
const expired = JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" });
|
||||
expect(verifyAddinTicket(expired, sign(expired), SECRET, now).ok, `exp=${exp}`).toBe(false);
|
||||
}
|
||||
const alive = JSON.stringify({ exp: now + 1, fid: "f", uid: "u", uname: "n" });
|
||||
expect(verifyAddinTicket(alive, sign(alive), SECRET, now).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("HMAC ถูกแต่ ticket ผิดกติกา (field หลุด allowlist / exp ไม่ใช่ integer) = ไม่ผ่าน", () => {
|
||||
const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 });
|
||||
expect(verifyAddinTicket(extra, sign(extra), SECRET).ok).toBe(false);
|
||||
const badExp = JSON.stringify({ exp: "123", fid: "f", uid: "u", uname: "n" });
|
||||
expect(verifyAddinTicket(badExp, sign(badExp), SECRET).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ticketToDocverClaims", () => {
|
||||
it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => {
|
||||
expect(
|
||||
ticketToDocverClaims({ exp: 1, fid: "F1", tid: "T12", uid: "oom", uname: "สรวิชญ์" }),
|
||||
).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: "T12" });
|
||||
expect(
|
||||
ticketToDocverClaims({ exp: 1, fid: "F1", tid: null, uid: "oom", uname: "สรวิชญ์" }),
|
||||
).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTicketTokenBody", () => {
|
||||
it("ครบทั้งคู่ = ได้ค่าตรง ๆ (ไม่แตะค่า — HMAC ต้องคำนวณบน string ดิบ)", () => {
|
||||
const digest = sign(TICKET_DATA);
|
||||
const result = parseTicketTokenBody({ ticket_data: TICKET_DATA, HmacSha256: digest });
|
||||
ok(result);
|
||||
expect(result.ticketData).toBe(TICKET_DATA);
|
||||
expect(result.hmacHex).toBe(digest);
|
||||
});
|
||||
|
||||
it("ไม่ใช่ object / มี field อื่นปน = invalid (→ 400)", () => {
|
||||
for (const raw of [[], "text", null, { ticket_data: "x", HmacSha256: "y", extra: 1 }]) {
|
||||
const result = parseTicketTokenBody(raw);
|
||||
expect(result.ok, JSON.stringify(raw)).toBe(false);
|
||||
if (!result.ok) expect(result.kind).toBe("invalid");
|
||||
}
|
||||
});
|
||||
|
||||
it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => {
|
||||
for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) {
|
||||
const result = parseTicketTokenBody(raw);
|
||||
expect(result.ok, JSON.stringify(raw)).toBe(false);
|
||||
if (!result.ok) expect(result.kind).toBe("missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("field ไม่ใช่ string / ยาวเกิน cap = invalid", () => {
|
||||
for (const raw of [
|
||||
{ ticket_data: 123, HmacSha256: "y" },
|
||||
{ ticket_data: "x", HmacSha256: "y".repeat(129) },
|
||||
{ ticket_data: "x".repeat(2049), HmacSha256: "y" },
|
||||
]) {
|
||||
const result = parseTicketTokenBody(raw);
|
||||
expect(result.ok, JSON.stringify(raw)).toBe(false);
|
||||
if (!result.ok) expect(result.kind).toBe("invalid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("addinTicketSecret", () => {
|
||||
// env save/restore — addinTicketSecret อ่านตอนเรียก
|
||||
const ORIG_ADDIN = process.env.ADDIN_TICKET_SECRET;
|
||||
const ORIG_SECRET = process.env.SECRET;
|
||||
|
||||
it("ลำดับ ADDIN_TICKET_SECRET → SECRET → default \"super_secret\" (trim ทุกตัว)", () => {
|
||||
delete process.env.ADDIN_TICKET_SECRET;
|
||||
delete process.env.SECRET;
|
||||
expect(addinTicketSecret()).toBe("super_secret");
|
||||
|
||||
process.env.SECRET = " tmpl-secret ";
|
||||
expect(addinTicketSecret()).toBe("tmpl-secret");
|
||||
|
||||
process.env.ADDIN_TICKET_SECRET = " addin-secret ";
|
||||
expect(addinTicketSecret()).toBe("addin-secret");
|
||||
|
||||
delete process.env.ADDIN_TICKET_SECRET;
|
||||
process.env.SECRET = " "; // ว่างล้วน = ข้ามไปตัวถัดไป
|
||||
expect(addinTicketSecret()).toBe("super_secret");
|
||||
|
||||
process.env.ADDIN_TICKET_SECRET = ORIG_ADDIN;
|
||||
process.env.SECRET = ORIG_SECRET;
|
||||
});
|
||||
});
|
||||
217
tests/lib/document-versioning.test.ts
Normal file
217
tests/lib/document-versioning.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DOCVER_MESSAGE_MAX,
|
||||
contentDispositionValue,
|
||||
docverError,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseDownloadFormat,
|
||||
parseFieldsJson,
|
||||
parseVersionIdParam,
|
||||
parseVersionUploadForm,
|
||||
sanitizeDisplayFileName,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
|
||||
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
|
||||
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
|
||||
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
|
||||
|
||||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
||||
describe("parseVersionIdParam", () => {
|
||||
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
|
||||
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
|
||||
ok(result);
|
||||
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
|
||||
});
|
||||
|
||||
it("ตกเมื่อไม่ใช่รูป UUID", () => {
|
||||
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
|
||||
expect(parseVersionIdParam(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDownloadFormat", () => {
|
||||
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
|
||||
for (const raw of [null, "", " ", " docx "]) {
|
||||
const result = parseDownloadFormat(raw);
|
||||
ok(result);
|
||||
expect(result.format).toBe("docx");
|
||||
}
|
||||
});
|
||||
|
||||
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
|
||||
const pdf = parseDownloadFormat("pdf");
|
||||
ok(pdf);
|
||||
expect(pdf.format).toBe("pdf");
|
||||
for (const raw of ["exe", "PDF", "docxx"]) {
|
||||
expect(parseDownloadFormat(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDocxBytes / isPdfBytes", () => {
|
||||
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
|
||||
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
|
||||
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
|
||||
});
|
||||
|
||||
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
|
||||
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
|
||||
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
|
||||
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
|
||||
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFieldsJson", () => {
|
||||
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
|
||||
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
|
||||
ok(result);
|
||||
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
|
||||
const empty = parseFieldsJson("{}");
|
||||
ok(empty);
|
||||
expect(empty.fields).toBeNull();
|
||||
});
|
||||
|
||||
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
|
||||
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
|
||||
expect(parseFieldsJson(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionUploadForm", () => {
|
||||
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
|
||||
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
|
||||
function baseForm(): FormData {
|
||||
const form = new FormData();
|
||||
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
||||
return form;
|
||||
}
|
||||
|
||||
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
|
||||
const form = baseForm();
|
||||
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
||||
form.append("message", " ");
|
||||
form.append("fields", '{"blackno":"1"}');
|
||||
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
||||
const result = parseVersionUploadForm(form);
|
||||
ok(result);
|
||||
expect(result.form).toEqual({
|
||||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||||
message: null,
|
||||
fileName: "คำสั่งเรียก.docx",
|
||||
fields: { blackno: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
|
||||
const form = baseForm();
|
||||
form.append("oops", "1");
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("oops");
|
||||
});
|
||||
|
||||
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
|
||||
for (const legacy of ["userId", "userName", "templateId"]) {
|
||||
const form = baseForm();
|
||||
form.append(legacy, "x");
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||||
const form = baseForm();
|
||||
form.append("message", new File(["x"], "m.txt"));
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("message");
|
||||
});
|
||||
|
||||
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
||||
const edge = baseForm();
|
||||
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
|
||||
ok(parseVersionUploadForm(edge));
|
||||
const over = baseForm();
|
||||
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
|
||||
expect(parseVersionUploadForm(over).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
|
||||
const form = baseForm();
|
||||
form.append("fields", '{"a":1}');
|
||||
expect(parseVersionUploadForm(form).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
||||
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
|
||||
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
|
||||
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
|
||||
expect(sanitizeDisplayFileName(" ")).toBeNull();
|
||||
expect(sanitizeDisplayFileName(null)).toBeNull();
|
||||
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
|
||||
const original = "คำสั่งเรียกเอกสาร.docx";
|
||||
const mangled = Buffer.from(original, "utf8").toString("latin1");
|
||||
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
|
||||
expect(sanitizeFilePartName(mangled)).toBe(original);
|
||||
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
|
||||
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docverError", () => {
|
||||
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
|
||||
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDispositionValue", () => {
|
||||
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
|
||||
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
|
||||
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
|
||||
});
|
||||
|
||||
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
|
||||
expect(contentDispositionValue("Report Final", "docx")).toBe(
|
||||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
|
||||
);
|
||||
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
|
||||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
|
||||
);
|
||||
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
|
||||
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
|
||||
);
|
||||
});
|
||||
|
||||
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
|
||||
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
|
||||
'attachment; filename="document.docx"; filename*=UTF-8\'\''
|
||||
+ encodeURIComponent("คำสั่งเรียก"),
|
||||
);
|
||||
});
|
||||
|
||||
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
|
||||
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
|
||||
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
|
||||
+ encodeURIComponent("รายงาน 2026"),
|
||||
);
|
||||
});
|
||||
});
|
||||
316
tests/lib/docver-token.test.ts
Normal file
316
tests/lib/docver-token.test.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
issueDocverToken,
|
||||
parseTokenRequestBody,
|
||||
requireDocverApiKey,
|
||||
requireDocverBearer,
|
||||
verifyDocverToken,
|
||||
type DocverTokenClaims,
|
||||
} from "@/lib/docver-token";
|
||||
|
||||
// docver token (PLAN-DOCVER.md §auth) — validation/auth ล้วนของ /api/documents/*:
|
||||
// ไฟล์นี้ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ
|
||||
// external-api-auth.test.ts — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ต้องตั้ง
|
||||
// DATABASE_URL/MINIO_*)
|
||||
|
||||
const SECRET = "test-secret-docver";
|
||||
const CLAIMS: DocverTokenClaims = {
|
||||
userid: "123",
|
||||
username: "Sorawit Bholsithi",
|
||||
fid: "3",
|
||||
template: "T12",
|
||||
};
|
||||
|
||||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
||||
// --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load
|
||||
const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY");
|
||||
const ORIG_API_KEY = process.env.DOCVER_API_KEY;
|
||||
const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET");
|
||||
const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET;
|
||||
|
||||
afterEach(() => {
|
||||
if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY;
|
||||
else delete process.env.DOCVER_API_KEY;
|
||||
if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET;
|
||||
else delete process.env.DOCVER_TOKEN_SECRET;
|
||||
});
|
||||
|
||||
function reqWithKey(key?: string): Request {
|
||||
return new Request("http://localhost/docedit/api/documents/token", {
|
||||
headers: key === undefined ? {} : { "x-api-key": key },
|
||||
});
|
||||
}
|
||||
|
||||
function reqWithBearer(authorization: string | null): Request {
|
||||
return new Request("http://localhost/docedit/api/documents/doc1/versions", {
|
||||
headers: authorization === null ? {} : { authorization },
|
||||
});
|
||||
}
|
||||
|
||||
async function bodyOf(res: Response): Promise<{ error: string }> {
|
||||
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||
return (await res.json()) as { error: string };
|
||||
}
|
||||
|
||||
/** เซ็น payload ใด ๆ ด้วย SECRET ผ่านมือ — ใช้ประกอบเคส claims ผิดรูปที่ issueDocverToken สร้างเองไม่ได้ */
|
||||
function signPayload(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString(
|
||||
"base64url",
|
||||
);
|
||||
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
const signature = createHmac("sha256", SECRET)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
describe("issueDocverToken / verifyDocverToken", () => {
|
||||
it("issue → verify ได้ claims ครบทุกตัว (userid/username/fid/template)", () => {
|
||||
const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET);
|
||||
ok(verified);
|
||||
expect(verified.claims).toEqual(CLAIMS);
|
||||
});
|
||||
|
||||
it("template เป็นทางเลือก (null ได้) แต่ fid บังคับ — token ไม่มี fid = ไม่ผ่าน", () => {
|
||||
const verified = verifyDocverToken(
|
||||
issueDocverToken({ userid: "u1", username: "ชื่อ", fid: "7", template: null }, SECRET, 60),
|
||||
SECRET,
|
||||
);
|
||||
ok(verified);
|
||||
expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: "7", template: null });
|
||||
const noFid = signPayload({
|
||||
userid: "u1",
|
||||
username: "n",
|
||||
template: "T1",
|
||||
iat: 0,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
});
|
||||
expect(verifyDocverToken(noFid, SECRET).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => {
|
||||
const token = issueDocverToken(CLAIMS, SECRET, 60);
|
||||
const [header, , signature] = token.split(".");
|
||||
const tamperedPayload = Buffer.from(
|
||||
JSON.stringify({ ...CLAIMS, userid: "999" }),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
expect(verifyDocverToken(`${header}.${tamperedPayload}.${signature}`, SECRET).ok).toBe(false);
|
||||
expect(verifyDocverToken(token, "wrong-secret").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("header alg อื่น (none / HS512) = ไม่ผ่าน แม้จะเซ็นด้วย secret ถูก (กัน alg-confusion)", () => {
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
for (const alg of ["none", "HS512"]) {
|
||||
const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" }), "utf8").toString("base64url");
|
||||
const signature = createHmac("sha256", SECRET)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
expect(verifyDocverToken(`${header}.${body}.${signature}`, SECRET).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("token หมดอายุ (ttl ติดลบ) หรือไม่มี exp = ไม่ผ่าน", () => {
|
||||
expect(verifyDocverToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET).ok).toBe(false);
|
||||
const noExp = signPayload({ ...CLAIMS, iat: Math.floor(Date.now() / 1000) });
|
||||
expect(verifyDocverToken(noExp, SECRET).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => {
|
||||
const now = Math.floor(Date.now() / 1000) + 60;
|
||||
for (const bad of [
|
||||
{ username: "n", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี userid
|
||||
{ userid: "u", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี username
|
||||
{ userid: "", username: "n", fid: "3", template: null, iat: 0, exp: now }, // userid ว่าง
|
||||
{ userid: "u", username: "n", fid: 3, template: null, iat: 0, exp: now }, // fid เป็น number
|
||||
{ userid: "u", username: "n", fid: "", template: null, iat: 0, exp: now }, // fid ว่าง
|
||||
{ userid: "u", username: "n", template: null, iat: 0, exp: now }, // ไม่มี fid (บังคับแล้ว)
|
||||
{ userid: "u", username: "n", fid: null, template: null, iat: 0, exp: now }, // fid null
|
||||
]) {
|
||||
expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("รูป token ผิด (ไม่ใช่ 3 segment / segment เป็น base64 ปกติไม่ใช่ base64url) = ไม่ผ่าน", () => {
|
||||
expect(verifyDocverToken("not-a-jwt", SECRET).ok).toBe(false);
|
||||
expect(verifyDocverToken("a.b.c.d", SECRET).ok).toBe(false);
|
||||
// base64 ปกติมี + / ซึ่ง base64url decode แล้วได้ byte ผิด → header/payload พัง
|
||||
expect(verifyDocverToken("???.???.???", SECRET).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireDocverApiKey", () => {
|
||||
it("env ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => {
|
||||
for (const value of [undefined, "", " "]) {
|
||||
if (value === undefined) delete process.env.DOCVER_API_KEY;
|
||||
else process.env.DOCVER_API_KEY = value;
|
||||
const res = requireDocverApiKey(reqWithKey("test-key"));
|
||||
expect(res?.status).toBe(401);
|
||||
expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => {
|
||||
process.env.DOCVER_API_KEY = "test-key";
|
||||
const missing = requireDocverApiKey(reqWithKey());
|
||||
const wrong = requireDocverApiKey(reqWithKey("not-the-key"));
|
||||
expect(missing?.status).toBe(401);
|
||||
expect(wrong?.status).toBe(401);
|
||||
expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!));
|
||||
});
|
||||
|
||||
it("key ตรง = ผ่าน (คืน null) · env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้", () => {
|
||||
process.env.DOCVER_API_KEY = "test-key";
|
||||
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
|
||||
process.env.DOCVER_API_KEY = " test-key ";
|
||||
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireDocverBearer", () => {
|
||||
it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => {
|
||||
const deniedBodies = new Set<string>();
|
||||
const cases: { label: string; secret: string | undefined; authorization: string | null }[] = [
|
||||
{ label: "ไม่มี Authorization", secret: SECRET, authorization: null },
|
||||
{ label: "scheme อื่น (Basic)", secret: SECRET, authorization: "Basic dXNlcjpwYXNz" },
|
||||
{ label: "Bearer ว่างเปล่า", secret: SECRET, authorization: "Bearer " },
|
||||
{ label: "token เน่า", secret: SECRET, authorization: "Bearer garbage.token.here" },
|
||||
{
|
||||
label: "token หมดอายุ",
|
||||
secret: SECRET,
|
||||
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, -10)}`,
|
||||
},
|
||||
{
|
||||
label: "เซ็นด้วย secret ผิด",
|
||||
secret: SECRET,
|
||||
authorization: `Bearer ${issueDocverToken(CLAIMS, "other-secret", 60)}`,
|
||||
},
|
||||
{ label: "secret ไม่ตั้ง (fail closed)", secret: undefined, authorization: null },
|
||||
{
|
||||
label: "secret ไม่ตั้งแม้จะมี token ถูก",
|
||||
secret: undefined,
|
||||
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`,
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
if (testCase.secret === undefined) delete process.env.DOCVER_TOKEN_SECRET;
|
||||
else process.env.DOCVER_TOKEN_SECRET = testCase.secret;
|
||||
const auth = requireDocverBearer(reqWithBearer(testCase.authorization));
|
||||
expect(auth.ok, testCase.label).toBe(false);
|
||||
if (!auth.ok) {
|
||||
expect(auth.response.status, testCase.label).toBe(401);
|
||||
deniedBodies.add(JSON.stringify(await bodyOf(auth.response)));
|
||||
}
|
||||
}
|
||||
expect(deniedBodies.size).toBe(1); // ข้อความ 401 เดียวกันหมด — ไม่ใช้ endpoint เป็น oracle
|
||||
});
|
||||
|
||||
it("Bearer ที่ verify ผ่าน = ได้ claims · คำว่า bearer พิมพ์เล็กก็รับ (case-insensitive)", () => {
|
||||
process.env.DOCVER_TOKEN_SECRET = SECRET;
|
||||
const token = issueDocverToken(CLAIMS, SECRET, 60);
|
||||
const auth = requireDocverBearer(reqWithBearer(`Bearer ${token}`));
|
||||
ok(auth);
|
||||
expect(auth.claims).toEqual(CLAIMS);
|
||||
const lower = requireDocverBearer(reqWithBearer(`bearer ${token}`));
|
||||
ok(lower);
|
||||
expect(lower.claims).toEqual(CLAIMS);
|
||||
});
|
||||
|
||||
it("env มี whitespace รอบ secret ก็ trim แล้ว verify ได้", () => {
|
||||
process.env.DOCVER_TOKEN_SECRET = ` ${SECRET} `;
|
||||
const auth = requireDocverBearer(
|
||||
reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`),
|
||||
);
|
||||
ok(auth);
|
||||
expect(auth.claims).toEqual(CLAIMS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTokenRequestBody", () => {
|
||||
it("ครบทุก field = ได้ claims (trim ค่า) · template ไม่ส่ง = null (fid บังคับ)", () => {
|
||||
const full = parseTokenRequestBody({
|
||||
userid: " 123 ",
|
||||
username: " Sorawit Bholsithi ",
|
||||
fid: " 3 ",
|
||||
template: " T12 ",
|
||||
});
|
||||
ok(full);
|
||||
expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12" });
|
||||
|
||||
const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit", fid: "3" });
|
||||
ok(minimal);
|
||||
expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: "3", template: null });
|
||||
});
|
||||
|
||||
it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => {
|
||||
const result = parseTokenRequestBody({ userid: "123", username: "n", fid: "3", template: " " });
|
||||
ok(result);
|
||||
expect(result.claims.template).toBeNull();
|
||||
});
|
||||
|
||||
it("ไม่ใช่ object (array / string / null) = 400", () => {
|
||||
for (const raw of [[], "text", null]) {
|
||||
const result = parseTokenRequestBody(raw);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("body ต้องเป็น JSON object");
|
||||
}
|
||||
});
|
||||
|
||||
it("key อื่นนอก allowlist = 400 ไม่รู้จัก field (กัน typo เงียบ ๆ)", () => {
|
||||
const result = parseTokenRequestBody({ userid: "1", username: "n", oops: 1 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("oops");
|
||||
});
|
||||
|
||||
it("ขาด userid หรือ username / ส่งมาไม่ใช่ string = 400 ข้อความเฉพาะ field", () => {
|
||||
for (const raw of [
|
||||
{ username: "n" },
|
||||
{ userid: " ", username: "n" },
|
||||
{ userid: 123, username: "n" },
|
||||
{ userid: "1" },
|
||||
{ userid: "1", username: "" },
|
||||
]) {
|
||||
const result = parseTokenRequestBody(raw);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/ต้องระบุ (userid|username)/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fid ขาด/null/ว่าง/ไม่ใช่ string (number) หรือ template ไม่ใช่ string = 400", () => {
|
||||
const missing = parseTokenRequestBody({ userid: "1", username: "n" });
|
||||
expect(missing.ok).toBe(false);
|
||||
if (!missing.ok) expect(missing.error).toContain("fid");
|
||||
for (const raw of [
|
||||
{ userid: "1", username: "n", fid: null },
|
||||
{ userid: "1", username: "n", fid: 3 },
|
||||
{ userid: "1", username: "n", fid: "" },
|
||||
{ userid: "1", username: "n", fid: " " },
|
||||
{ userid: "1", username: "n", template: 12 },
|
||||
]) {
|
||||
expect(parseTokenRequestBody(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("ความยาวเกิน cap: userid > 100 / username > 200 / fid > 200 / template > 100 = 400", () => {
|
||||
expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false);
|
||||
expect(
|
||||
parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseTokenRequestBody({ userid: "1", username: "n", fid: "a".repeat(201) }).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
118
tests/utils/docver-ticket.test.ts
Normal file
118
tests/utils/docver-ticket.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isTicketExpired,
|
||||
normalizeHexDigest,
|
||||
parseDocverTicket,
|
||||
timingSafeEqualHex,
|
||||
} from "@/utils/docver-ticket";
|
||||
|
||||
// PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย
|
||||
// รูป ticket_data อ้างอิงจากตัว stamp จริงของกลุ่ม template (app/api/template/
|
||||
// render/route.ts applyTemplate — key เรียงตามตัวอักษรหลังเติม exp)
|
||||
|
||||
const VALID = JSON.stringify({
|
||||
exp: 1793067433,
|
||||
fid: "T12_FD155_3s-long_ZZZ",
|
||||
tid: "T12",
|
||||
uid: "oom",
|
||||
uname: "สรวิชญ์",
|
||||
});
|
||||
|
||||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
||||
describe("parseDocverTicket", () => {
|
||||
it("ticket ครบทุก field = แกะได้ (trim ค่า string)", () => {
|
||||
const result = parseDocverTicket(VALID);
|
||||
ok(result);
|
||||
expect(result.ticket).toEqual({
|
||||
exp: 1793067433,
|
||||
fid: "T12_FD155_3s-long_ZZZ",
|
||||
tid: "T12",
|
||||
uid: "oom",
|
||||
uname: "สรวิชญ์",
|
||||
});
|
||||
});
|
||||
|
||||
it("tid optional — ไม่ส่ง/ส่งค่าว่าง = null", () => {
|
||||
const noTid = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }));
|
||||
ok(noTid);
|
||||
expect(noTid.ticket.tid).toBeNull();
|
||||
const blankTid = parseDocverTicket(
|
||||
JSON.stringify({ exp: 1, fid: "f", tid: " ", uid: "u", uname: "n" }),
|
||||
);
|
||||
ok(blankTid);
|
||||
expect(blankTid.ticket.tid).toBeNull();
|
||||
});
|
||||
|
||||
it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null) = ไม่ผ่าน", () => {
|
||||
for (const raw of ["not json", "[1,2]", '"text"', "null", "{}"]) {
|
||||
expect(parseDocverTicket(raw).ok, raw).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("field อื่นนอก allowlist = ไม่ผ่าน (กัน typo/field หลุดจาก spec)", () => {
|
||||
const result = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", oops: 1 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("oops");
|
||||
});
|
||||
|
||||
it("exp ไม่ใช่จำนวนเต็ม / หาย / uid-uname-fid หายหรือไม่ใช่ string = ไม่ผ่าน", () => {
|
||||
for (const ticket of [
|
||||
{ fid: "f", uid: "u", uname: "n" }, // ไม่มี exp
|
||||
{ exp: "1793067433", fid: "f", uid: "u", uname: "n" }, // exp เป็น string
|
||||
{ exp: 1.5, fid: "f", uid: "u", uname: "n" }, // exp ไม่ใช่ integer
|
||||
{ exp: 1, uid: "u", uname: "n" }, // ไม่มี fid
|
||||
{ exp: 1, fid: "f", uname: "n" }, // ไม่มี uid
|
||||
{ exp: 1, fid: "f", uid: "u" }, // ไม่มี uname
|
||||
{ exp: 1, fid: "", uid: "u", uname: "n" }, // fid ว่าง
|
||||
{ exp: 1, fid: 3, uid: "u", uname: "n" }, // fid เป็น number
|
||||
{ exp: 1, fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string
|
||||
]) {
|
||||
expect(parseDocverTicket(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => {
|
||||
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false);
|
||||
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false);
|
||||
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false);
|
||||
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTicketExpired", () => {
|
||||
it("exp อนาคต = ยังไม่หมด · exp ตรง now พอดี/อดีต = หมดแล้ว", () => {
|
||||
const now = 1_793_000_000;
|
||||
expect(isTicketExpired(now + 1, now)).toBe(false);
|
||||
expect(isTicketExpired(now, now)).toBe(true);
|
||||
expect(isTicketExpired(now - 1, now)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHexDigest", () => {
|
||||
const HEX64 = "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9";
|
||||
|
||||
it("hex lowercase ครบ 64 = ผ่าน · trim และ uppercase ก็ normalize ให้", () => {
|
||||
expect(normalizeHexDigest(HEX64)).toBe(HEX64);
|
||||
expect(normalizeHexDigest(` ${HEX64} `)).toBe(HEX64);
|
||||
expect(normalizeHexDigest(HEX64.toUpperCase())).toBe(HEX64);
|
||||
});
|
||||
|
||||
it("รูปไม่ใช่ (สั้น/ยาว/ไม่ใช่ hex/ว่าง) = null", () => {
|
||||
for (const bad of ["", "abc", `${HEX64}00`, `g${HEX64.slice(1)}`, " "]) {
|
||||
expect(normalizeHexDigest(bad), bad).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("timingSafeEqualHex", () => {
|
||||
it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => {
|
||||
const a = "ab".repeat(32);
|
||||
expect(timingSafeEqualHex(a, a)).toBe(true);
|
||||
expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false);
|
||||
expect(timingSafeEqualHex(a, "ab")).toBe(false);
|
||||
});
|
||||
});
|
||||
116
tests/wordaddin/ticket-auth-flow.test.ts
Normal file
116
tests/wordaddin/ticket-auth-flow.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// @vitest-environment jsdom
|
||||
// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer:
|
||||
// happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ,
|
||||
// memory fallback ของ session, และทุก fail path ต้องคืน result ไม่ใช่ throw
|
||||
// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง)
|
||||
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";
|
||||
|
||||
// จับ error ทุกทาง (window error / unhandled rejection) — flow ต้องไม่ปล่อยอะไรหลุดเลย
|
||||
const errors: unknown[] = [];
|
||||
function trapErrors() {
|
||||
window.addEventListener("error", (event) => errors.push(event.error ?? event.message));
|
||||
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
|
||||
}
|
||||
|
||||
function makeTicket(): { ticketData: string; ticketHmac: string } {
|
||||
const ticket = {
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
fid: "DBG_FID_001",
|
||||
tid: "T12",
|
||||
uid: "oom",
|
||||
uname: "สรวิชญ์",
|
||||
};
|
||||
const ticketData = JSON.stringify(ticket);
|
||||
const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex");
|
||||
return { ticketData, ticketHmac };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
// memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส
|
||||
ticketService.clearSession();
|
||||
authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket)
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
errors.length = 0;
|
||||
});
|
||||
|
||||
describe("signInWithTicket (service layer)", () => {
|
||||
it("B: signInWithTicket happy-path (มี crypto.subtle + fetch ตอบ 200) ไม่ต้อง throw", async () => {
|
||||
trapErrors();
|
||||
// jsdom ไม่มี crypto.subtle — ใส่ webcrypto ของ node แทน (secure context ของ webview จริงมีอยู่แล้ว)
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
|
||||
const { ticketData, ticketHmac } = makeTicket();
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: [
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
Buffer.from(
|
||||
JSON.stringify({ userid: "oom", fid: "DBG_FID_001", exp: Math.floor(Date.now() / 1000) + 3600 }),
|
||||
).toString("base64url"),
|
||||
"sig",
|
||||
].join("."),
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await authService.signInWithTicket({ ticketData, ticketHmac });
|
||||
expect(first.ok, JSON.stringify(first)).toBe(true);
|
||||
|
||||
// เปิดเอกสารเดิมซ้ำ — ต้องไม่ยิง /token ซ้ำ (ใช้ token เดิมจาก localStorage)
|
||||
await authService.signInWithTicket({ ticketData, ticketHmac });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(window.localStorage.getItem("wordaddin-auth")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("C: signInWithTicket แบบไม่มี crypto.subtle (webview เก่า) ต้องจบเป็น no-access ไม่ใช่ throw", async () => {
|
||||
trapErrors();
|
||||
const noSubtle = { getRandomValues: () => new Uint8Array(0) };
|
||||
vi.stubGlobal("crypto", noSubtle); // ไม่มี .subtle เลย
|
||||
const { ticketData, ticketHmac } = makeTicket();
|
||||
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("D: ticket ผิดทุกแบบ (ขาด/hex เพี้ยน/parse ไม่ผ่าน/หมดอายุ/fetch 401) ต้องคืน result ไม่ใช่ throw", async () => {
|
||||
trapErrors();
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })),
|
||||
);
|
||||
const { ticketData, ticketHmac } = makeTicket();
|
||||
const bad = [
|
||||
{ ticketData: undefined, ticketHmac: undefined },
|
||||
{ ticketData: "not-json", ticketHmac },
|
||||
{ ticketData, ticketHmac: "zz".repeat(32) },
|
||||
{
|
||||
ticketData: JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), // หมดอายุ + HMAC ถูก
|
||||
ticketHmac: createHmac("sha256", "super_secret")
|
||||
.update(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), "utf8")
|
||||
.digest("hex"),
|
||||
},
|
||||
{ ticketData, ticketHmac }, // ถูก แต่ server 401 → token-request-failed
|
||||
] satisfies { ticketData?: string; ticketHmac?: string }[];
|
||||
for (const input of bad) {
|
||||
const result = await authService.signInWithTicket(input);
|
||||
expect(result.ok, JSON.stringify(input)).toBe(false);
|
||||
if (!result.ok) expect(typeof result.message).toBe("string");
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,11 +4,13 @@
|
|||
// basePath ให้อัตโนมัติ (path ที่เขียนถึง route จึงต้องมี /note นำหน้าเอง เช่น "/note/login")
|
||||
// ยกเว้น API route อยู่ใต้ app/api/note/** (URL /docedit/api/note/…) — path ที่ขึ้นต้น
|
||||
// "/api/" เติมแค่ basePath (caller เขียน "/api/note/…" เอง) ไม่มี /note ซ้อน
|
||||
// และ static ของ sibling wordaddin ใต้ public/wordaddin/** (URL /docedit/wordaddin/…
|
||||
// เช่น office.js ที่ self-host) — path ที่ขึ้นต้น "/wordaddin/" เติมแค่ basePath เช่นกัน
|
||||
// (pdfUrl ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้เท่านั้น)
|
||||
export function withBasePath(path: string): string {
|
||||
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/api/")) {
|
||||
if (path.startsWith("/api/") || path.startsWith("/wordaddin/")) {
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
|
||||
}
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${process.env.NEXT_PUBLIC_APP_PATH ?? ""}${path}`;
|
||||
|
|
|
|||
136
utils/docver-ticket.ts
Normal file
136
utils/docver-ticket.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` +
|
||||
// `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่
|
||||
// ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token)
|
||||
// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts)
|
||||
//
|
||||
// กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256
|
||||
// ของตัว stamp ฝั่ง template):
|
||||
// - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร
|
||||
// หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize**
|
||||
// - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase
|
||||
// ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้)
|
||||
// - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) —
|
||||
// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง
|
||||
// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน
|
||||
|
||||
/** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */
|
||||
export interface DocverTicket {
|
||||
/** หมดอายุเมื่อไร — unix epoch วินาที */
|
||||
exp: number;
|
||||
/** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */
|
||||
fid: string;
|
||||
/** template ต้นทาง — optional (ไม่มี = null) */
|
||||
tid: string | null;
|
||||
/** รหัสผู้ใช้ → claim `userid` */
|
||||
uid: string;
|
||||
/** ชื่อผู้ใช้ → claim `username` */
|
||||
uname: string;
|
||||
}
|
||||
|
||||
/** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */
|
||||
const TICKET_LIMITS = {
|
||||
uid: 100,
|
||||
uname: 200,
|
||||
fid: 200,
|
||||
tid: 100,
|
||||
} as const;
|
||||
|
||||
/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */
|
||||
const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const;
|
||||
|
||||
export type DocverTicketParseResult =
|
||||
| { ok: true; ticket: DocverTicket }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data`
|
||||
* คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ
|
||||
*/
|
||||
export function parseDocverTicket(raw: string): DocverTicketParseResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: "ticket_data ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(TICKET_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const exp = record.exp;
|
||||
if (typeof exp !== "number" || !Number.isSafeInteger(exp)) {
|
||||
return { ok: false, error: "ticket_data ต้องมี exp เป็นจำนวนเต็ม (unix วินาที)" };
|
||||
}
|
||||
|
||||
const field = (name: "uid" | "uname" | "fid"): string | null => {
|
||||
const value = record[name];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null;
|
||||
};
|
||||
|
||||
const uid = field("uid");
|
||||
if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" };
|
||||
const uname = field("uname");
|
||||
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
|
||||
const fid = field("fid");
|
||||
if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" };
|
||||
|
||||
let tid: string | null = null;
|
||||
if (record.tid !== undefined) {
|
||||
if (typeof record.tid !== "string") {
|
||||
return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" };
|
||||
}
|
||||
const trimmed = record.tid.trim();
|
||||
if (trimmed !== "") {
|
||||
if (trimmed.length > TICKET_LIMITS.tid) {
|
||||
return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
tid = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, ticket: { exp, fid, tid, uid, uname } };
|
||||
}
|
||||
|
||||
/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */
|
||||
export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean {
|
||||
return exp <= nowSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) —
|
||||
* trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่
|
||||
*/
|
||||
export function normalizeHexDigest(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS —
|
||||
* ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts
|
||||
* ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว
|
||||
*/
|
||||
export function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */
|
||||
export type HmacSha256Hex = (message: string, secret: string) => Promise<string>;
|
||||
41
utils/request-origin.ts
Normal file
41
utils/request-origin.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// origin เต็มของ request ที่เข้ามา — ใช้ประกอบ URL เต็มที่จะส่งกลับออกไปข้างนอก
|
||||
// (deep link ของ N10 / manifest.xml ของ wordaddin) เพราะ request ที่เห็นฝั่ง app
|
||||
// มักผ่าน reverse proxy หลัง (IIS ของทางศาล) จึงต้องดู header ของ proxy ก่อน
|
||||
//
|
||||
// ถอดมาจาก requestOrigin เดิมใน app/api/note/external/documents/route.ts
|
||||
// (2026-10-16) ให้ route manifest ของ wordaddin ใช้ร่วม
|
||||
|
||||
/** origin เต็มของ request นี้: เชื่อ header ของ reverse proxy ก่อน
|
||||
* (x-forwarded-proto / x-forwarded-host — proxy หลายชั้นส่งเป็น comma list
|
||||
* ใช้ค่าแรก) แล้วค่อย fallback จาก host/protocol ของ request ตรง ๆ
|
||||
*
|
||||
* แล้วบังคับ https เมื่อโฮสต์ปลายทางไม่ใช่ localhost แต่ได้ proto เป็น http:
|
||||
* hop ภายในหลัง proxy เป็น http ปกติ (อาจไม่มี x-forwarded-proto ให้ — Next
|
||||
* server ก็ฉีด `x-forwarded-proto: http` ให้เองด้วย เชื่อ "ใครเป็นคนตั้ง" ไม่ได้)
|
||||
* ส่วนผู้บริโภค URL พวกนี้ (Word สำหรับ manifest — รับ https เท่านั้น ยกเว้น
|
||||
* localhost, browser สำหรับ deep link ของ N10) เข้าถึงโฮสต์จริงผ่าน https
|
||||
* เสมอ — ค่า http://<โฮสต์ที่ไม่ใช่ localhost> ใช้ไม่ได้จริง จึงชี้ขาดจากตัว
|
||||
* โฮสต์ปลายทางแทนการเดาว่า header มาจาก proxy หรือถูกฉีด */
|
||||
export function requestOrigin(req: Request): string {
|
||||
const url = new URL(req.url);
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("host") ??
|
||||
url.host;
|
||||
const forwardedProto =
|
||||
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
|
||||
url.protocol.replace(":", "");
|
||||
// host อาจติด port มาด้วย (localhost:3000) — ถอดออกก่อนเทียบ (URL จัดการ
|
||||
// IPv6 ในวงเล็บ [::1] ให้ด้วย เขียน split(":") เองจะพัง)
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(`http://${host}`).hostname;
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
})();
|
||||
const isLocal =
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
const proto = !isLocal && forwardedProto === "http" ? "https" : forwardedProto;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
20
wordaddin/config.ts
Normal file
20
wordaddin/config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
|
||||
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
|
||||
*
|
||||
* ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
|
||||
* ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl
|
||||
*/
|
||||
export const config = {
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
|
||||
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
|
||||
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
|
||||
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01",
|
||||
/**
|
||||
* secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate — ตัดเคสไฟล์ถูกแก้ก่อนยิง network;
|
||||
* ความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น เพราะค่าใน bundle สกัดได้) — ต้องตรงกับ
|
||||
* ตัว stamp ของ e-Court และ env ADDIN_TICKET_SECRET/SECRET ฝั่ง server (PLAN-ADDIN-AUTH.md §7)
|
||||
*/
|
||||
ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret",
|
||||
};
|
||||
58
wordaddin/models/versionRecord.ts
Normal file
58
wordaddin/models/versionRecord.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\models\versionRecord.ts
|
||||
*/
|
||||
|
||||
/** response ของ POST /api/documents/{documentId}/versions */
|
||||
export interface UploadVersionResult {
|
||||
hasPdf: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
|
||||
export interface FieldSnapshotEntry {
|
||||
value: string;
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
|
||||
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
|
||||
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
|
||||
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
|
||||
*/
|
||||
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
|
||||
|
||||
/** item ของ GET /api/documents/{documentId}/versions */
|
||||
export interface VersionRecord {
|
||||
hasPdf?: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
/** ISO-8601 */
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
/** SHA256 hex */
|
||||
contentHash: string;
|
||||
fields: FieldsSnapshot | null;
|
||||
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
|
||||
export function readSnapshotField(
|
||||
snapshot: FieldsSnapshot | null,
|
||||
key: string
|
||||
): FieldSnapshotEntry | null {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const raw = snapshot[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return { value: raw, missing: false };
|
||||
}
|
||||
return { value: raw.value, missing: raw.missing };
|
||||
}
|
||||
126
wordaddin/services/apiClient.ts
Normal file
126
wordaddin/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { config } from "../config";
|
||||
import * as authService from "./authService";
|
||||
|
||||
/**
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService
|
||||
* ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
|
||||
* โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError:
|
||||
* UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
||||
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
||||
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
||||
*/
|
||||
|
||||
/** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit | null;
|
||||
}
|
||||
|
||||
function toUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accessToken: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return fetch(toUrl(path), {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน
|
||||
* (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง
|
||||
* ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์
|
||||
* ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง
|
||||
*/
|
||||
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||
const accessToken = await authService.getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
const response = await doFetch(path, options, accessToken);
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
if (await authService.renewTokenFromTicket()) {
|
||||
const retryToken = await authService.getAccessToken();
|
||||
if (retryToken) {
|
||||
const retry = await doFetch(path, options, retryToken);
|
||||
if (retry.status !== 401) {
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
} catch {
|
||||
// plain text — ใช้ตรงๆ
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
||||
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
||||
const response = await apiFetch(path);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
||||
export async function apiSendExpectJson<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
fallbackError: string
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
179
wordaddin/services/authService.ts
Normal file
179
wordaddin/services/authService.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม
|
||||
* docs/requirement/addin-workflow.md)
|
||||
*
|
||||
* ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
|
||||
* + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
|
||||
* HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก
|
||||
* /api/documents/token เก็บตาม fid ("คีย์คือ fid")
|
||||
*
|
||||
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
|
||||
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
|
||||
* ตอน token หมดอายุกลาง session
|
||||
*/
|
||||
|
||||
import { config } from "../config";
|
||||
import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket";
|
||||
import * as ticketService from "./ticketService";
|
||||
|
||||
/** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */
|
||||
export type SignInFailureReason = "missing" | "tampered" | "expired" | "token-request-failed";
|
||||
|
||||
export type SignInResult = { ok: true } | { ok: false; reason: SignInFailureReason; message: string };
|
||||
|
||||
/** state ของ taskpane instance นี้ (ต่อเอกสารที่เปิด) — null = ยังไม่ได้ sign in */
|
||||
let username: string | null = null;
|
||||
/** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */
|
||||
let currentFid: string | null = null;
|
||||
/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */
|
||||
let currentTicket: { data: string; hmac: string } | null = null;
|
||||
|
||||
/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
|
||||
export function signInForDemo(): void {
|
||||
username = config.mockUsername;
|
||||
currentFid = null;
|
||||
currentTicket = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
|
||||
* 1. ตรวจมี ticket_data + HmacSha256 ครบ
|
||||
* 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
|
||||
* 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า
|
||||
* แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token
|
||||
* หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม
|
||||
*/
|
||||
export async function signInWithTicket(input: {
|
||||
ticketData?: string;
|
||||
ticketHmac?: string;
|
||||
}): Promise<SignInResult> {
|
||||
const ticketData = input.ticketData ?? "";
|
||||
const ticketHmac = input.ticketHmac ?? "";
|
||||
// Word custom property จำกัด value ~255 ตัวอักษร — เกิน = ไฟล์เสียหาย (ถูกตัด)
|
||||
if (ticketData === "" || ticketHmac === "" || ticketData.length > 255 || ticketHmac.length > 255) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing",
|
||||
message: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseDocverTicket(ticketData);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "tampered",
|
||||
message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย",
|
||||
};
|
||||
}
|
||||
if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "tampered",
|
||||
message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข",
|
||||
};
|
||||
}
|
||||
const ticket = parsed.ticket;
|
||||
const ticketAlive = !isTicketExpired(ticket.exp);
|
||||
|
||||
const session = ticketService.loadSession();
|
||||
if (session === null || session.userId !== ticket.uid) {
|
||||
// ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน
|
||||
if (!ticketAlive) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "expired",
|
||||
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
|
||||
};
|
||||
}
|
||||
ticketService.clearSession();
|
||||
let entry: ticketService.SessionTokenEntry;
|
||||
try {
|
||||
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "token-request-failed",
|
||||
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
||||
};
|
||||
}
|
||||
ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } });
|
||||
} else {
|
||||
// คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้)
|
||||
session.userName = ticket.uname;
|
||||
let entry = session.tokens[ticket.fid] ?? null;
|
||||
if (entry !== null && ticketService.isTokenExpired(entry)) {
|
||||
// token หมดอายุ — renew ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ
|
||||
// เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน
|
||||
if (ticketAlive) {
|
||||
try {
|
||||
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||
session.tokens[ticket.fid] = entry;
|
||||
} catch {
|
||||
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry === null) {
|
||||
// เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม
|
||||
if (!ticketAlive) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "expired",
|
||||
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
|
||||
};
|
||||
}
|
||||
try {
|
||||
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "token-request-failed",
|
||||
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
||||
};
|
||||
}
|
||||
}
|
||||
ticketService.saveSession(session);
|
||||
}
|
||||
|
||||
currentFid = ticket.fid;
|
||||
currentTicket = { data: ticketData, hmac: ticketHmac };
|
||||
username = ticket.uname;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) —
|
||||
* เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ
|
||||
* คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่)
|
||||
*/
|
||||
export async function renewTokenFromTicket(): Promise<boolean> {
|
||||
if (currentTicket === null || currentFid === null) return false;
|
||||
const parsed = parseDocverTicket(currentTicket.data);
|
||||
if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false;
|
||||
try {
|
||||
const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac);
|
||||
const session =
|
||||
ticketService.loadSession() ??
|
||||
{ userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} };
|
||||
session.userName = parsed.ticket.uname;
|
||||
session.tokens[parsed.ticket.fid] = entry;
|
||||
ticketService.saveSession(session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
if (currentFid !== null) {
|
||||
return ticketService.loadSession()?.tokens[currentFid]?.token ?? null;
|
||||
}
|
||||
// demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม
|
||||
return username ? `mock-token-${username}` : null;
|
||||
}
|
||||
|
||||
export function getUsername(): string | undefined {
|
||||
return username ?? undefined;
|
||||
}
|
||||
208
wordaddin/services/autoSaveService.ts
Normal file
208
wordaddin/services/autoSaveService.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* global console */
|
||||
|
||||
/**
|
||||
* FR-17 — Auto Save: บันทึก local (`document.save`) เท่านั้น ห้ามสร้าง version อัตโนมัติ
|
||||
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
|
||||
*
|
||||
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
|
||||
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
|
||||
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
|
||||
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
|
||||
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
|
||||
*/
|
||||
|
||||
import { isDocumentDirty, saveDocument } from "./documentService";
|
||||
|
||||
export interface AutoSaveConfig {
|
||||
enabled: boolean;
|
||||
debounceMs: number;
|
||||
periodicSaveEnabled: boolean;
|
||||
periodicIntervalMs: number;
|
||||
}
|
||||
|
||||
/** TS constants ตาม spec §8 — แก้ที่นี่ที่เดียว (ไม่มี runtime config) */
|
||||
export const autoSaveConfig: AutoSaveConfig = {
|
||||
enabled: true,
|
||||
debounceMs: 3_000,
|
||||
periodicSaveEnabled: true,
|
||||
periodicIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
class WordAutoSaveService {
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private saving = false;
|
||||
private pendingSave = false;
|
||||
/** depth ของ suspend() — จะรัน timer ใหม่เฉพาะเมื่อ resume ครบทุกชั้น */
|
||||
private suspendDepth = 0;
|
||||
private idleWaiters: Array<() => void> = [];
|
||||
private started = false;
|
||||
private uncommitted = false;
|
||||
|
||||
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
|
||||
onError: ((error: unknown) => void) | undefined;
|
||||
/** save สำเร็จหนึ่งรอบ — App ใช้เคลียร์ banner error เดิม */
|
||||
onSaved: (() => void) | undefined;
|
||||
/** state เปลี่ยน (isSaving / hasUncommittedSinceVersion) — App sync React state */
|
||||
onStateChange: (() => void) | undefined;
|
||||
|
||||
get isSaving(): boolean {
|
||||
return this.saving;
|
||||
}
|
||||
|
||||
/** FR-3 — เคยเห็น dirty หลัง version ล่าสุด (เคลียร์เมื่อสร้าง version สำเร็จ) */
|
||||
get hasUncommittedSinceVersion(): boolean {
|
||||
return this.uncommitted;
|
||||
}
|
||||
|
||||
/** FR-17 §16 — เริ่มตอน Office.onReady ได้เลย ไม่ต้องรอ sign-in */
|
||||
start(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.startTimers();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false;
|
||||
this.stopTimers();
|
||||
}
|
||||
|
||||
/** flow ที่จัดการ save เอง (FR-2/FR-18) — หยุด timer + รอ save ค้างจบ แล้วค่อยทำงานต่อ */
|
||||
async suspend(): Promise<void> {
|
||||
this.suspendDepth++;
|
||||
this.stopTimers();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** คู่ของ suspend() — ซ้อนกี่ชั้นต้อง resume ครบก่อน timer ถึงจะกลับมา */
|
||||
resume(): void {
|
||||
if (this.suspendDepth > 0) {
|
||||
this.suspendDepth--;
|
||||
}
|
||||
if (this.suspendDepth === 0) {
|
||||
this.startTimers();
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
|
||||
notifyDocumentChanged(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.clearDebounce();
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = undefined;
|
||||
this.save().catch((error) => this.reportError(error));
|
||||
}, autoSaveConfig.debounceMs);
|
||||
}
|
||||
|
||||
/** บันทึกเดี๋ยวนี้ (ยกเลิก debounce ค้าง) — ถ้ามี save กำลังทำงานจะ mark pending แทน (coalesce) */
|
||||
async save(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
if (this.saving) {
|
||||
this.pendingSave = true;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.notifyState();
|
||||
try {
|
||||
await saveDocument();
|
||||
if (this.pendingSave) {
|
||||
this.pendingSave = false;
|
||||
await saveDocument();
|
||||
}
|
||||
this.onSaved?.();
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
this.saving = false;
|
||||
this.notifyState();
|
||||
this.resolveIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกให้เสร็จก่อนกลับ — ใช้ก่อน close/upload (spec auto-save ข้อ 12) เรียกหลัง suspend() เสมอ */
|
||||
async forceSave(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
await this.waitIdle();
|
||||
await this.save();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** FR-3 — version ใหม่ถูกสร้างสำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" */
|
||||
clearUncommittedSinceVersion(): void {
|
||||
this.uncommitted = false;
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
private startTimers(): void {
|
||||
if (!this.started || !autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (autoSaveConfig.periodicSaveEnabled && !this.periodicTimer) {
|
||||
this.periodicTimer = setInterval(() => {
|
||||
void this.periodicTick();
|
||||
}, autoSaveConfig.periodicIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private stopTimers(): void {
|
||||
this.clearDebounce();
|
||||
if (this.periodicTimer) {
|
||||
clearInterval(this.periodicTimer);
|
||||
this.periodicTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearDebounce(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async periodicTick(): Promise<void> {
|
||||
if (this.saving || this.suspendDepth > 0) {
|
||||
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
|
||||
}
|
||||
try {
|
||||
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
|
||||
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
|
||||
if (!(await isDocumentDirty())) {
|
||||
return;
|
||||
}
|
||||
this.uncommitted = true;
|
||||
this.notifyState();
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private waitIdle(): Promise<void> {
|
||||
return this.saving
|
||||
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
private resolveIdle(): void {
|
||||
const waiters = this.idleWaiters;
|
||||
this.idleWaiters = [];
|
||||
for (const waiter of waiters) {
|
||||
waiter();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyState(): void {
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
private reportError(error: unknown): void {
|
||||
console.warn("auto-save failed:", error);
|
||||
this.onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const autoSaveService = new WordAutoSaveService();
|
||||
86
wordaddin/services/documentService.ts
Normal file
86
wordaddin/services/documentService.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/* global Word, Office */
|
||||
|
||||
/**
|
||||
* FR-17 / FR-18 — จุดเดียวที่คุยกับ document-level API ของ Word
|
||||
* (native save / อ่าน dirty / ปิดเอกสาร) แยกจาก versionFlow เพื่อให้
|
||||
* autoSaveService กับปุ่ม [บันทึก] ใช้ร่วมได้โดยไม่ต้องพ่วง flow versioning
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\documentService.ts
|
||||
*/
|
||||
|
||||
export enum DocumentCloseMode {
|
||||
/** บันทึกการแก้ไขก่อนปิด */
|
||||
Save = "Save",
|
||||
/** ปิดโดยไม่บันทึก — ไว้ใช้ระดับ service เท่านั้น (UI ปัจจุบันไม่มีปุ่มไหนใช้) */
|
||||
SkipSave = "SkipSave",
|
||||
}
|
||||
|
||||
/** native save ตาม FR-2 ขั้นตอนที่ 4 — ใช้ร่วมทั้ง versionFlow และ autoSaveService
|
||||
* เรียกแบบไม่ส่ง saveBehavior: ปลอดภัยทุกรุ่น runtime — รุ่นเก่า (npm 1.1.110) ไม่มี
|
||||
* enum Word.SaveBehavior และรับ save() แบบไม่มี argument, รุ่นใหม่ param เป็น optional
|
||||
* ที่ default เดียวกัน (behavior = save เงียบ ๆ) */
|
||||
export async function saveDocument(): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
context.document.save();
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-3 — มีการแก้ไขที่ยังไม่ถูกบันทึกลงไฟล์ local หรือไม่ */
|
||||
export async function isDocumentDirty(): Promise<boolean> {
|
||||
return Word.run(async (context) => {
|
||||
context.document.load("saved");
|
||||
await context.sync();
|
||||
return !context.document.saved;
|
||||
});
|
||||
}
|
||||
|
||||
/** save แล้วเช็คว่า Word เห็นไฟล์สะอาดจริง — บางสถานะ (เช่น หลังเขียน custom properties /
|
||||
* custom XML part ของ flow versioning) Word ยังรายงาน saved=false แม้ save ผ่านแล้ว
|
||||
* จึง save ซ้ำแบบมีขอบเขต (รวมสูงสุด 3 รอบ) — ไม่งั้น periodic tick ของ auto-save
|
||||
* เห็น dirty ตอนไม่มีใครแก้อะไร แล้ว banner FR-3 ("มีการแก้ไขที่ยังไม่เป็น version")
|
||||
* โผล่ผิด ๆ หลังจบ flow */
|
||||
export async function saveDocumentVerified(): Promise<void> {
|
||||
await saveDocument();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
if (!(await isDocumentDirty())) return;
|
||||
await saveDocument();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-18 — `document.close()` ต้องการ WordApi 1.5 **และใช้ไม่ได้บน Word on the web**
|
||||
* (requirement-set table ระบุรองรับ แต่หน้าเอกสารของ method เขียนไว้ว่า "isn't
|
||||
* supported in Word on the web") จึงต้องเช็ค platform คู่กันเสมอ —
|
||||
* UI ใช้ซ่อนปุ่ม close ทุกแบบ, service ใช้ guard ก่อนเรียกจริง
|
||||
* (เรียกนอก Word — เช่น SSR หรือ browser ธรรมดา — throw ถูก catch คืน false เสมอ)
|
||||
*/
|
||||
export function canCloseDocument(): boolean {
|
||||
try {
|
||||
return (
|
||||
Office.context.requirements.isSetSupported("WordApi", "1.5") &&
|
||||
Office.context.platform !== Office.PlatformType.OfficeOnline
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-18 — ปิดเอกสารปัจจุบัน (task pane จะปิดตามเอกสาร) */
|
||||
export async function closeDocument(mode: DocumentCloseMode = DocumentCloseMode.Save): Promise<void> {
|
||||
if (!canCloseDocument()) {
|
||||
throw new Error(
|
||||
"เวอร์ชัน Word นี้ไม่รองรับการปิดเอกสารจาก add-in (รองรับเฉพาะ Word Desktop ที่ใช้ WordApi 1.5 ได้)"
|
||||
);
|
||||
}
|
||||
await Word.run(async (context) => {
|
||||
// ส่งค่า underlying ของ enum เป็น string ตรง ๆ ไม่อ่าน Word.CloseBehavior.* —
|
||||
// runtime self-host อาจเป็นรุ่นเก่ากว่า @types (กรณี npm 1.1.110 เดิม: ไม่มี enum นี้เลย
|
||||
// อ่านแล้ว TypeError "reading 'save'"; ปัจจุบัน fetch ฉบับ CDN ล่าสุดมี enum แล้วแต่ค่า
|
||||
// string ทำงานเหมือนกันทุกรุ่น — cast กัน @types ที่พิมพ์ parameter เป็น enum object)
|
||||
context.document.close(
|
||||
(mode === DocumentCloseMode.Save ? "Save" : "SkipSave") as Word.CloseBehavior
|
||||
);
|
||||
// ห้ามเรียก context.sync() หลัง close() — เอกสารปิดกลาง batch ทำให้ sync ถูก abort
|
||||
// (ตัวอย่างทางการของ Word เรียก close เฉยๆ แล้วพึ่ง implicit sync ตอน run() จบ)
|
||||
});
|
||||
}
|
||||
28
wordaddin/services/errors.ts
Normal file
28
wordaddin/services/errors.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** เหมือน getErrorMessage แต่ต่อท้าย code/errorLocation จาก debugInfo ของ OfficeExtension.Error —
|
||||
* สำหรับ error จาก Word.run ที่ต้องการสาเหตุละเอียด (เช่น ราย tag ที่เขียนค่าไม่สำเร็จ) */
|
||||
export function getDetailedErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
|
||||
): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
const debugInfo = (error as { debugInfo?: { code?: string; errorLocation?: string } }).debugInfo;
|
||||
const code = debugInfo?.code ? ` [${debugInfo.code}]` : "";
|
||||
const location = debugInfo?.errorLocation ? ` (${debugInfo.errorLocation})` : "";
|
||||
return `${error.message}${code}${location}`;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
128
wordaddin/services/fileService.ts
Normal file
128
wordaddin/services/fileService.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* global Office, console */
|
||||
|
||||
/**
|
||||
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
|
||||
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\fileService.ts
|
||||
*/
|
||||
|
||||
export const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
const SLICE_SIZE = 1024 * 1024;
|
||||
|
||||
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
|
||||
return new Error(result.error?.message || fallback);
|
||||
}
|
||||
|
||||
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบุแค่ "byte array"):
|
||||
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
|
||||
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
|
||||
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
|
||||
*/
|
||||
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
|
||||
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
|
||||
return Uint8Array.from(data);
|
||||
}
|
||||
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
|
||||
}
|
||||
|
||||
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
|
||||
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
file.getSliceAsync(sliceIndex, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
try {
|
||||
resolve(sliceDataToBytes(result.value.data));
|
||||
} catch (error) {
|
||||
reject(
|
||||
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
|
||||
function closeFileQuietly(file: Office.File): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
file.closeAsync(() => resolve());
|
||||
} catch (error) {
|
||||
console.warn("closeAsync failed:", error);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
|
||||
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
|
||||
*/
|
||||
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
|
||||
const file = await getFileAsync(fileType);
|
||||
try {
|
||||
const chunks: BlobPart[] = [];
|
||||
for (let index = 0; index < file.sliceCount; index++) {
|
||||
chunks.push(await getSliceAsync(file, index));
|
||||
}
|
||||
return new Blob(chunks, { type: contentType });
|
||||
} finally {
|
||||
await closeFileQuietly(file);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentPdf(): Promise<Blob> {
|
||||
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
|
||||
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
|
||||
}
|
||||
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
|
||||
}
|
||||
257
wordaddin/services/metadataService.ts
Normal file
257
wordaddin/services/metadataService.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/* global Word, console */
|
||||
|
||||
/**
|
||||
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
|
||||
* - Custom Document Properties = primary flag ตรวจจับ tracked document
|
||||
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
|
||||
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
|
||||
*
|
||||
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
|
||||
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||
*/
|
||||
|
||||
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||
|
||||
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
|
||||
|
||||
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
|
||||
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
||||
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
||||
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
||||
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
||||
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
||||
|
||||
/** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in
|
||||
* (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */
|
||||
export const TICKET_DATA_PROPERTY = "ticket_data";
|
||||
export const TICKET_HMAC_PROPERTY = "HmacSha256";
|
||||
|
||||
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||
export interface TrackedDocumentProperties {
|
||||
savedByAddIn?: string;
|
||||
documentTrackingId?: string;
|
||||
lastVersionId?: string;
|
||||
lastVersionTimestamp?: string;
|
||||
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
||||
templateId?: string;
|
||||
/** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */
|
||||
ticketData?: string;
|
||||
ticketHmac?: string;
|
||||
}
|
||||
|
||||
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||
export interface XmlVersionEntry {
|
||||
id: string;
|
||||
number: number;
|
||||
timestamp: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
|
||||
* + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */
|
||||
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||
return Word.run(async (context) => {
|
||||
const properties = context.document.properties.customProperties;
|
||||
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
|
||||
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
||||
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
||||
const ticketData = properties.getItemOrNullObject(TICKET_DATA_PROPERTY);
|
||||
const ticketHmac = properties.getItemOrNullObject(TICKET_HMAC_PROPERTY);
|
||||
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||
savedByAddIn.load("value");
|
||||
documentTrackingId.load("value");
|
||||
lastVersionId.load("value");
|
||||
lastVersionTimestamp.load("value");
|
||||
templateId.load("value");
|
||||
ticketData.load("value");
|
||||
ticketHmac.load("value");
|
||||
await context.sync();
|
||||
|
||||
const read = (property: Word.CustomProperty): string | undefined =>
|
||||
property.isNullObject || property.value === undefined || property.value === null
|
||||
? undefined
|
||||
: String(property.value);
|
||||
|
||||
return {
|
||||
savedByAddIn: read(savedByAddIn),
|
||||
documentTrackingId: read(documentTrackingId),
|
||||
lastVersionId: read(lastVersionId),
|
||||
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||
templateId: read(templateId),
|
||||
ticketData: read(ticketData),
|
||||
ticketHmac: read(ticketHmac),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
|
||||
* หมายเหตุ: TaskpaneApp.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
|
||||
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
|
||||
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
|
||||
export async function isTrackedDocument(): Promise<boolean> {
|
||||
const properties = await readTrackedProperties();
|
||||
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
||||
}
|
||||
|
||||
export interface StampPropertiesInput {
|
||||
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
|
||||
documentTrackingId?: string;
|
||||
lastVersionId?: string;
|
||||
lastVersionTimestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
|
||||
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
|
||||
*/
|
||||
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
const properties = context.document.properties.customProperties;
|
||||
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
|
||||
if (input.documentTrackingId !== undefined) {
|
||||
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
|
||||
}
|
||||
if (input.lastVersionId !== undefined) {
|
||||
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
|
||||
}
|
||||
if (input.lastVersionTimestamp !== undefined) {
|
||||
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
|
||||
}
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||
export function generateGuid(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeXmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
|
||||
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
|
||||
const elements = entries
|
||||
.map(
|
||||
(entry) =>
|
||||
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
|
||||
entry.timestamp
|
||||
)}" user="${escapeXmlAttribute(entry.user)}"/>`
|
||||
)
|
||||
.join("\n");
|
||||
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
|
||||
}
|
||||
|
||||
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
|
||||
return nodes
|
||||
.map((node) => ({
|
||||
id: node.getAttribute("id") ?? "",
|
||||
number: Number(node.getAttribute("number") ?? Number.NaN),
|
||||
timestamp: node.getAttribute("timestamp") ?? "",
|
||||
user: node.getAttribute("user") ?? "",
|
||||
}))
|
||||
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
|
||||
}
|
||||
|
||||
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
|
||||
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
|
||||
try {
|
||||
return await Word.run(async (context) => {
|
||||
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||
context.load(scoped);
|
||||
await context.sync();
|
||||
const xmlResults = scoped.items.map((part) => part.getXml());
|
||||
await context.sync();
|
||||
|
||||
const entries: XmlVersionEntry[] = [];
|
||||
for (const result of xmlResults) {
|
||||
entries.push(...parseVersionHistoryXml(result.value));
|
||||
}
|
||||
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
|
||||
return entries;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
|
||||
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
|
||||
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
|
||||
*/
|
||||
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
|
||||
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||
context.load(scoped);
|
||||
await context.sync();
|
||||
for (const part of scoped.items) {
|
||||
part.delete();
|
||||
}
|
||||
if (scoped.items.length > 0) {
|
||||
await context.sync();
|
||||
}
|
||||
// 2) add ของใหม่
|
||||
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
|
||||
await context.sync();
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content controls → fields (FR-11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
|
||||
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
|
||||
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
|
||||
*/
|
||||
export async function readContentControlFields(): Promise<Record<string, string>> {
|
||||
return Word.run(async (context) => {
|
||||
const controls = context.document.contentControls;
|
||||
controls.load("tag, text");
|
||||
await context.sync();
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const control of controls.items) {
|
||||
const tag = (control.tag ?? "").trim();
|
||||
if (tag === "") {
|
||||
continue;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
|
||||
fields[tag] = control.text;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
}
|
||||
240
wordaddin/services/templateService.ts
Normal file
240
wordaddin/services/templateService.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
/* global Word, Office */
|
||||
|
||||
/**
|
||||
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls
|
||||
* ฝั่ง Office.js ล้วน ไม่ยิง backend: สแกนรายช่อง / กระโดดไปเลือกช่อง / เขียนค่าทดสอบกลับ
|
||||
* เข้าเอกสาร / แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้าที่ cursor
|
||||
*
|
||||
* API ที่ใช้เป็น WordApi 1.1 เกือบทั้งหมด (ยืนยันจาก @types/office-js 1.0.612) ยกเว้น
|
||||
* getByIdOrNullObject (1.3) — ไม่เกินความต้องการต่ำสุดของ manifest (1.3) จึงใช้ได้ทุก platform
|
||||
* รวม Office 2019 และ Word on the web
|
||||
*
|
||||
* ⚠️ collection `contentControls` ธรรมดามองเห็น**เฉพาะ rich text CC** (docstring ของ
|
||||
* Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น
|
||||
* plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ)
|
||||
* ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3
|
||||
* ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน)
|
||||
*
|
||||
* กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน
|
||||
* browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume
|
||||
* auto-save คู่กันเสมอ และเรียก notifyDocumentChanged() หลังสำเร็จให้ debounce และ flag FR-3
|
||||
* ทำงานถูกต้อง (pattern เดียวกับ versionFlow.ts)
|
||||
*/
|
||||
|
||||
import { autoSaveService } from "./autoSaveService";
|
||||
import { getDetailedErrorMessage } from "./errors";
|
||||
|
||||
/** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js
|
||||
* เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */
|
||||
export interface ScannedField {
|
||||
id: number;
|
||||
/** tag ดิบไม่ trim — เพื่อให้ UI เตือน "tag ว่าง / มี space นำ-ท้าย" ได้ (ตอนเทียบ/ใช้งานคือ trim เสมอ) */
|
||||
tag: string;
|
||||
title: string;
|
||||
text: string;
|
||||
/** enum ดิบ เช่น "RichTextInline" — ไว้ debug ใน tooltip */
|
||||
type: string;
|
||||
/** heuristics หยาบจาก enum type: ลงท้ายด้วย "Inline" = ในบรรทัด ไม่งั้น = บล็อก */
|
||||
kind: "inline" | "block";
|
||||
}
|
||||
|
||||
function toScannedField(control: Word.ContentControl): ScannedField {
|
||||
const type = control.type ?? "";
|
||||
return {
|
||||
id: control.id,
|
||||
tag: control.tag ?? "",
|
||||
title: control.title ?? "",
|
||||
text: control.text ?? "",
|
||||
type,
|
||||
kind: type.endsWith("Inline") ? "inline" : "block",
|
||||
};
|
||||
}
|
||||
|
||||
/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น
|
||||
* (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */
|
||||
export function supportsAllContentControlTypes(): boolean {
|
||||
try {
|
||||
return Office.context.requirements.isSetSupported("WordApi", "1.5");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด)
|
||||
* ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */
|
||||
function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection {
|
||||
return supportsAllContentControlTypes()
|
||||
? context.document.getContentControls()
|
||||
: context.document.contentControls;
|
||||
}
|
||||
|
||||
/** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error
|
||||
* อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */
|
||||
export async function listContentControls(): Promise<ScannedField[]> {
|
||||
return Word.run(async (context) => {
|
||||
const controls = supportedContentControls(context);
|
||||
controls.load("id, tag, title, text, type");
|
||||
await context.sync();
|
||||
return controls.items.map(toScannedField);
|
||||
});
|
||||
}
|
||||
|
||||
/** เลือก + เลื่อน Word ไปที่ช่องตาม id ของผลสแกน — select() ตาม docstring จะ scroll ให้เอง
|
||||
* ไม่ใช่การแก้เอกสาร (document.saved ไม่ dirty) จึงไม่ต้อง suspend */
|
||||
export async function selectContentControl(id: number): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
const control = supportedContentControls(context).getByIdOrNullObject(id);
|
||||
control.load("id");
|
||||
await context.sync();
|
||||
if (control.isNullObject) {
|
||||
throw new Error("ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่");
|
||||
}
|
||||
control.select();
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/** แทรกย่อหน้าว่างใหม่หลังตำแหน่ง cursor (เหมือนกด Enter) — ปุ่มเสริมตอนจัดโครงสร้าง template
|
||||
* (คำขอผู้ใช้ 2026-10-19: ตอนแก้ template ต้องเพิ่มย่อหน้าบ่อย แล้วเปิด formatting marks ตรวจ —
|
||||
* ส่วนการเปิด/ปิด formatting marks เป็น application setting ที่ Office.js ไม่มี API ให้แตะ) */
|
||||
export async function insertParagraphAtSelection(): Promise<void> {
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
context.document.getSelection().insertParagraph("", "After");
|
||||
await context.sync();
|
||||
});
|
||||
autoSaveService.notifyDocumentChanged();
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
/** page break แบบ paragraph สมบูรณ์ (block-level) — w:p ครอบ run ที่มี <w:br w:type="page"/>
|
||||
* ใช้เป็นวิธีสำรองเมื่อ insertBreak("Page") โดน E_FAIL (0x80004005) ในบางบริบท เช่น cursor
|
||||
* อยู่ในตาราง (เคสจริงบน T3.docx 2026-10-19 — template ของศาลมีตารางเป็นโครงหลัก) · ระดับ fragment
|
||||
* ต้องเป็น block-level เต็มตัว ไม่ใช่ run เดี่ยว (ลองแล้วยัง E_FAIL อีกรอบ) */
|
||||
const PAGE_BREAK_PARAGRAPH_OOXML =
|
||||
'<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:r><w:br w:type="page"/></w:r></w:p>';
|
||||
|
||||
/** แทรกตัวแบ่งที่ตำแหน่ง cursor — "line" = ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter) สำหรับ
|
||||
* จัดที่อยู่/รายการให้ตรงแนว, "page" = ขึ้นหน้าใหม่ (Ctrl+Enter) สำหรับเอกสารหลายหน้า (คำขอผู้ใช้ 2026-10-19) */
|
||||
export async function insertBreakAtSelection(breakKind: "line" | "page"): Promise<void> {
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
if (breakKind === "line") {
|
||||
await Word.run(async (context) => {
|
||||
context.document.getSelection().insertBreak("Line", "After");
|
||||
await context.sync();
|
||||
});
|
||||
} else {
|
||||
// วิธีมาตรฐานก่อน — ถ้า host ปฏิเสธ (E_FAIL ในบางบริบท) สลับไป OOXML block-level
|
||||
// (sync ที่พัง = คำสั่งถูกปฏิเสธ ไม่มีอะไรถูกเขียนค้าง จึง retry ได้; ถ้า retry ก็พัง
|
||||
// โยน error ที่รวมทั้งสองสาเหตุ ไม่กลืนต้นเหตุแรกหาย)
|
||||
let firstError: unknown;
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
context.document.getSelection().insertBreak("Page", "After");
|
||||
await context.sync();
|
||||
});
|
||||
autoSaveService.notifyDocumentChanged();
|
||||
return;
|
||||
} catch (error) {
|
||||
firstError = error;
|
||||
}
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
context.document.getSelection().insertOoxml(PAGE_BREAK_PARAGRAPH_OOXML, "End");
|
||||
await context.sync();
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
throw new Error(
|
||||
`แทรกตัวแบ่งหน้าไม่สำเร็จทั้งสองวิธี — insertBreak: ${getDetailedErrorMessage(firstError)} · OOXML: ${getDetailedErrorMessage(fallbackError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
autoSaveService.notifyDocumentChanged();
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyFailure {
|
||||
tag: string;
|
||||
/** สาเหตุที่อ่านได้ (รวม code/errorLocation จาก OfficeExtension.Error ถ้ามี) */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ApplyTestValuesResult {
|
||||
/** จำนวนช่อง (CC) ที่ถูกเขียนค่าจริง — tag เดียวซ้ำหลายช่องนับทุกช่อง
|
||||
* ⚠️ ข้อจำกัด granularity ราย tag: ถ้า tag หนึ่งผูกหลายช่องและ batch sync พังกลางคัน
|
||||
* (ช่องแรกถูกเขียนแล้วแต่ช่องหลังโดนปฏิเสธ) ทั้ง tag จะถูกนับเป็น failures แม้บางช่องเขียนสำเร็จ —
|
||||
* เรียก listContentControls หลังเขียนเพื่อเห็นค่าจริงในเอกสาร */
|
||||
appliedCount: number;
|
||||
/** tag ที่ส่งค่ามาแต่ไม่มีช่องตรงในเอกสารตอนเขียน (เช่น ถูกลบไปหลังสแกน) */
|
||||
missingTags: string[];
|
||||
/** tag ที่ Word ปฏิเสธการเขียน (เช่น เอกสารถูก Restrict Editing หรือช่องถูกล็อค/แก้ไม่ได้) —
|
||||
* เขียนแบบแยกราย tag เพื่อให้เหลืออื่น ๆ ยังสำเร็จและรู้สาเหตุเป็นรายตัว */
|
||||
failures: ApplyFailure[];
|
||||
}
|
||||
|
||||
/** เขียนค่าทดสอบกลับเข้าทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่อง แบบตัวแปรเดียวหลายจุด)
|
||||
* - ค่าที่เว้นว่างจะไม่ถูกแตะ (กันเผลอล้างค่าจริงของช่องนั้น) — ว่างทั้งหมด = ไม่เปิด Word.run
|
||||
* - อ่าน id+tag สดรอบเดียว ไม่เชื่อ snapshot สแกน แล้วเขียน**แยก Word.run ราย tag** —
|
||||
* tag ที่ Word ปฏิเสธ (เอกสารถูก Restrict Editing / ช่องแก้ไม่ได้) fail เฉพาะตัว เหลืออื่นยังเขียนได้
|
||||
* และเหตุผลถูกเก็บลง failures (กรณีเคยเจอจริง: error 0x800A16C6 บนเอกสารที่ make-protected ล็อค) */
|
||||
export async function applyTestValues(values: Record<string, string>): Promise<ApplyTestValuesResult> {
|
||||
const entries = Object.entries(values).filter(([, value]) => value.trim() !== "");
|
||||
if (entries.length === 0) {
|
||||
return { appliedCount: 0, missingTags: [], failures: [] };
|
||||
}
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
// สำรวจ id ของ CC ราย tag รอบเดียว (เทียบหลัง trim เหมือนฟอร์ม) — id ใช้ได้ทั้ง session นี้
|
||||
const idsByTag = new Map<string, number[]>();
|
||||
await Word.run(async (context) => {
|
||||
const controls = supportedContentControls(context);
|
||||
controls.load("id, tag");
|
||||
await context.sync();
|
||||
for (const control of controls.items) {
|
||||
const tag = (control.tag ?? "").trim();
|
||||
if (tag === "" || !(tag in values)) continue;
|
||||
const ids = idsByTag.get(tag) ?? [];
|
||||
ids.push(control.id);
|
||||
idsByTag.set(tag, ids);
|
||||
}
|
||||
});
|
||||
|
||||
let appliedCount = 0;
|
||||
const missingTags: string[] = [];
|
||||
const failures: ApplyFailure[] = [];
|
||||
for (const [tag, value] of entries) {
|
||||
const ids = idsByTag.get(tag);
|
||||
if (!ids || ids.length === 0) {
|
||||
missingTags.push(tag);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let written = 0;
|
||||
await Word.run(async (context) => {
|
||||
const targets = ids.map((id) => supportedContentControls(context).getByIdOrNullObject(id));
|
||||
targets.forEach((control) => control.load("id"));
|
||||
await context.sync();
|
||||
const alive = targets.filter((control) => !control.isNullObject);
|
||||
alive.forEach((control) => control.insertText(value, "Replace"));
|
||||
await context.sync();
|
||||
written = alive.length;
|
||||
});
|
||||
appliedCount += written;
|
||||
} catch (tagError) {
|
||||
failures.push({ tag, reason: getDetailedErrorMessage(tagError) });
|
||||
}
|
||||
}
|
||||
if (appliedCount > 0) {
|
||||
autoSaveService.notifyDocumentChanged();
|
||||
}
|
||||
return { appliedCount, missingTags, failures };
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
176
wordaddin/services/ticketService.ts
Normal file
176
wordaddin/services/ticketService.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow
|
||||
* (PLAN-ADDIN-AUTH.md §5):
|
||||
* - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส
|
||||
* ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น)
|
||||
* - session store: localStorage key เดียว `wordaddin-auth` — userId/userName +
|
||||
* map token ต่อ fid · webview block localStorage = fallback memory ต่อ session
|
||||
* (pattern เดียวกับ versioningService)
|
||||
* - requestDocverToken: POST same-origin /api/documents/token แบบ body ticket —
|
||||
* ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง)
|
||||
*/
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { normalizeHexDigest } from "@/utils/docver-ticket";
|
||||
|
||||
/** key เดียวของ session — v ไว้ migrate รูปในอนาคต */
|
||||
const SESSION_STORAGE_KEY = "wordaddin-auth";
|
||||
|
||||
export interface SessionTokenEntry {
|
||||
token: string;
|
||||
/** exp ของ JWT (unix วินาที) — decode จาก payload ตอนรับ */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface AddinSession {
|
||||
userId: string;
|
||||
userName: string;
|
||||
/** token ต่อ fid — "คีย์คือ fid" ตาม requirement */
|
||||
tokens: Record<string, SessionTokenEntry>;
|
||||
}
|
||||
|
||||
/** memory fallback — ใช้เมื่อ localStorage โดน block (webview บางตัว/private mode) */
|
||||
let memorySession: AddinSession | null = null;
|
||||
|
||||
function parseSession(raw: string | null): AddinSession | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AddinSession;
|
||||
if (
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.userName !== "string" ||
|
||||
parsed.userId === "" ||
|
||||
typeof parsed.tokens !== "object" ||
|
||||
parsed.tokens === null ||
|
||||
Array.isArray(parsed.tokens)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const entry of Object.values(parsed.tokens)) {
|
||||
if (typeof entry.token !== "string" || typeof entry.exp !== "number") return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSession(): AddinSession | null {
|
||||
if (typeof window === "undefined") return memorySession; // SSR ของ client component
|
||||
try {
|
||||
return parseSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) ?? memorySession;
|
||||
} catch {
|
||||
return memorySession;
|
||||
}
|
||||
}
|
||||
|
||||
/** เขียน session — localStorage พัง = เก็บ memory ต่อ (ไม่ throw) */
|
||||
export function saveSession(session: AddinSession): void {
|
||||
memorySession = session;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// ignore — quota/private mode: มี memory ของ session นี้อยู่
|
||||
}
|
||||
}
|
||||
|
||||
/** ล้างทั้งก้อน — ใช้ตอนเจอ user ใหม่ (requirement ขั้น 6: เคลียร์ Session เดิม) */
|
||||
export function clearSession(): void {
|
||||
memorySession = null;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** token หมดอายุหรือยัง (มี margin 30 วิ กันขอบเขตตกหล่น) */
|
||||
export function isTokenExpired(entry: SessionTokenEntry): boolean {
|
||||
return entry.exp * 1000 <= Date.now() - 30_000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */
|
||||
export async function hmacSha256HexWeb(message: string, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
||||
return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก
|
||||
* property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase
|
||||
*/
|
||||
export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise<boolean> {
|
||||
const expected = normalizeHexDigest(expectedHex);
|
||||
if (expected === null) return false;
|
||||
try {
|
||||
return (await hmacSha256HexWeb(ticketData, secret)) === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ขอ token จาก /api/documents/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/** ถอด exp จาก JWT payload โดยไม่ตรวจ signature (แม่นกว่า expires_in — ใช้นาฬิกา server) */
|
||||
function jwtExp(token: string): number | undefined {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return undefined;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
|
||||
const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||
const exp = typeof json === "object" && json !== null ? (json as Record<string, unknown>).exp : undefined;
|
||||
return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ
|
||||
* (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow)
|
||||
*/
|
||||
export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise<SessionTokenEntry> {
|
||||
const response = await fetch(withBasePath("/api/documents/token"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`);
|
||||
}
|
||||
let data: TokenResponse;
|
||||
try {
|
||||
data = (await response.json()) as TokenResponse;
|
||||
} catch {
|
||||
throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)");
|
||||
}
|
||||
if (typeof data.access_token !== "string" || data.access_token === "") {
|
||||
throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)");
|
||||
}
|
||||
const exp =
|
||||
jwtExp(data.access_token) ??
|
||||
(typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0);
|
||||
return { token: data.access_token, exp };
|
||||
}
|
||||
229
wordaddin/services/versionFlow.ts
Normal file
229
wordaddin/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/* global Office, console */
|
||||
|
||||
/**
|
||||
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\versionFlow.ts
|
||||
*
|
||||
* - `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
|
||||
*
|
||||
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
|
||||
* (backend คือ source of truth ของ id จริงอยู่ดี)
|
||||
*/
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { autoSaveService } from "./autoSaveService";
|
||||
import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./documentService";
|
||||
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
readVersionHistoryFromXml,
|
||||
stampTrackedProperties,
|
||||
writeVersionHistoryToXml,
|
||||
XmlVersionEntry,
|
||||
} from "./metadataService";
|
||||
import { uploadVersion } from "./versioningService";
|
||||
|
||||
export interface StartVersioningResult {
|
||||
hasPdf: boolean;
|
||||
documentId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
export interface SaveAndVersionResult {
|
||||
hasPdf: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-18 — version ถูกบันทึกสำเร็จแล้วแต่ปิดเอกสารไม่สำเร็จ (เอกสารยังเปิดอยู่)
|
||||
* UI ต้องแยกสถานะนี้ออกจาก "สร้าง version ไม่สำเร็จ" — version ปลอดภัยบน backend แล้ว
|
||||
*/
|
||||
export class VersionSavedButCloseFailedError extends Error {
|
||||
constructor(
|
||||
public readonly result: SaveAndVersionResult,
|
||||
cause: unknown
|
||||
) {
|
||||
super(
|
||||
`บันทึก version ${result.versionNumber} เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: ${
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
} — ปิดเอกสารเองได้เลย`
|
||||
);
|
||||
this.name = "VersionSavedButCloseFailedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
|
||||
function guessDocumentFileName(): string | undefined {
|
||||
try {
|
||||
const url = Office.context.document.url;
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const name = url.split(/[\\/]/).pop();
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** `context.document.save()` ย้ายไป documentService (ใช้ร่วมกับ FR-17 auto-save) */
|
||||
|
||||
async function uploadCurrentFile(
|
||||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>,
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void,
|
||||
templateId?: string
|
||||
): Promise<SaveAndVersionResult> {
|
||||
const file = await getCurrentDocumentFile();
|
||||
let pdf: Blob | undefined;
|
||||
onProgress?.("กำลังสร้างสำเนา PDF…");
|
||||
try {
|
||||
pdf = await getCurrentDocumentPdf();
|
||||
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
|
||||
} catch (error) {
|
||||
console.warn("PDF capture failed:", error);
|
||||
pdf = undefined;
|
||||
}
|
||||
onProgress?.("กำลังอัปโหลด version…");
|
||||
return uploadVersion(
|
||||
documentId,
|
||||
file,
|
||||
clientVersionId,
|
||||
fields,
|
||||
guessDocumentFileName(),
|
||||
message,
|
||||
pdf,
|
||||
templateId
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
||||
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||
* (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */
|
||||
export async function startVersioning(
|
||||
message?: string,
|
||||
templateId?: string,
|
||||
onProgress?: (progress: string) => void
|
||||
): Promise<StartVersioningResult> {
|
||||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
const documentId = generateGuid();
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
|
||||
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
|
||||
await stampTrackedProperties({
|
||||
documentTrackingId: documentId,
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part (secondary — fail ได้เงียบๆ)
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
await writeVersionHistoryToXml([
|
||||
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||
]);
|
||||
|
||||
// 3) native save แล้ว capture ไฟล์ + fields
|
||||
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);
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
return { documentId, ...result };
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว)
|
||||
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||
* (FR-18: closeAfter=true — หลัง upload สำเร็จเท่านั้น ปิดเอกสารด้วย close(Save);
|
||||
* ปิดไม่สำเร็จโยน VersionSavedButCloseFailedError ไม่ใช่ error ของ version) */
|
||||
export async function saveAndVersion(
|
||||
documentId: string,
|
||||
latestVersionNumber: number,
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void,
|
||||
closeAfter = false
|
||||
): Promise<SaveAndVersionResult> {
|
||||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
const predictedNumber = latestVersionNumber + 1;
|
||||
const user = authService.getUsername() ?? "unknown";
|
||||
|
||||
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
|
||||
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
|
||||
await stampTrackedProperties({
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
const entries = await readVersionHistoryFromXml();
|
||||
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||
await writeVersionHistoryToXml(entries);
|
||||
|
||||
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
const corrected = await readVersionHistoryFromXml();
|
||||
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
|
||||
if (entry) {
|
||||
entry.number = result.versionNumber;
|
||||
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
|
||||
await writeVersionHistoryToXml(corrected);
|
||||
}
|
||||
}
|
||||
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
|
||||
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
|
||||
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||
if (closeAfter) {
|
||||
try {
|
||||
await closeDocument(DocumentCloseMode.Save);
|
||||
} catch (error) {
|
||||
throw new VersionSavedButCloseFailedError(result, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
159
wordaddin/services/versioningService.ts
Normal file
159
wordaddin/services/versioningService.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
|
||||
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
|
||||
*
|
||||
* - คงชื่อ/ลายเซ็น 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
|
||||
*/
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { generateGuid } from "./metadataService";
|
||||
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
|
||||
interface StoredVersion {
|
||||
record: VersionRecord;
|
||||
docxBlob: Blob | null;
|
||||
pdfBlob: Blob | null;
|
||||
}
|
||||
|
||||
/** key = documentId, เก็บเรียงเก่า → ใหม่ */
|
||||
const store = new Map<string, StoredVersion[]>();
|
||||
|
||||
const STORAGE_KEY = "wordaddin-mock-versions";
|
||||
|
||||
/** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */
|
||||
function loadPersisted(): Record<string, VersionRecord[]> {
|
||||
try {
|
||||
if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Record<string, VersionRecord[]>) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const persisted = loadPersisted();
|
||||
for (const [documentId, records] of Object.entries(persisted)) {
|
||||
if (Array.isArray(records)) {
|
||||
store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null })));
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */
|
||||
function persist(): void {
|
||||
try {
|
||||
const snapshot: Record<string, VersionRecord[]> = {};
|
||||
store.forEach((versions, documentId) => {
|
||||
snapshot[documentId] = versions.map((item) => item.record);
|
||||
});
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// ignore — quota/private mode
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */
|
||||
async function mockContentHash(file: Blob): Promise<string> {
|
||||
try {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} catch {
|
||||
return "mock-hash";
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
|
||||
* + FR-13: message คือ commit message ทางเลือก
|
||||
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
|
||||
export async function uploadVersion(
|
||||
documentId: string,
|
||||
file: Blob,
|
||||
clientVersionId: string,
|
||||
fields?: Record<string, string>,
|
||||
fileName?: string,
|
||||
message?: string,
|
||||
pdf?: Blob,
|
||||
templateId?: string
|
||||
): Promise<UploadVersionResult> {
|
||||
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
|
||||
void fileName;
|
||||
void templateId;
|
||||
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
|
||||
|
||||
const versions = store.get(documentId) ?? [];
|
||||
const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1;
|
||||
const hasPdf = !!pdf && pdf.size > 0;
|
||||
const trimmedMessage = message?.trim();
|
||||
|
||||
const record: VersionRecord = {
|
||||
versionId: generateGuid(),
|
||||
versionNumber,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "unknown",
|
||||
contentHash: await mockContentHash(file),
|
||||
message: trimmedMessage || null,
|
||||
hasPdf,
|
||||
fields: fields && Object.keys(fields).length > 0 ? fields : null,
|
||||
};
|
||||
versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null });
|
||||
store.set(documentId, versions);
|
||||
persist();
|
||||
return { hasPdf, versionId: record.versionId, versionNumber };
|
||||
}
|
||||
|
||||
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
|
||||
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||
await delay(200);
|
||||
const versions = store.get(documentId) ?? [];
|
||||
return versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.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<VersionDownloadLink> {
|
||||
void documentId;
|
||||
void versionId;
|
||||
void format;
|
||||
await delay(150);
|
||||
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||
}
|
||||
|
||||
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
|
||||
export function newClientVersionId(): string {
|
||||
return generateGuid();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue