diff --git a/.env.example b/.env.example index 7af635b..7e1100d 100644 --- a/.env.example +++ b/.env.example @@ -5,41 +5,3 @@ 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 diff --git a/.gitignore b/.gitignore index 6ef65e7..c42a1e7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,14 +31,5 @@ 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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f5ccb40..8621a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,6 @@ - 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`) — ปัจจุบัน auth + 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) @@ -21,7 +20,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`) · เช่นกัน **กลุ่ม 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` +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`) 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 จะทำให้ความหนาเส้นบิดเบี้ยว) @@ -29,19 +28,16 @@ 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 ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`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) +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 ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`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) ## โครงสร้างไฟล์ (ตามแผน) ``` -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 ล้วน) 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/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 — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon) 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) @@ -49,18 +45,7 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม) lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) -lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + 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): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) -lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) -wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) -wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) -wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -<<<<<<< HEAD -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) -======= -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 = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) ->>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a +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 ต้องมาแก้ไฟล์นี้ให้ตรงด้วย utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -83,17 +68,12 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) -app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-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` ของ
(กฎ 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 + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) -components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · 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 ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "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 หน้า) diff --git a/README.md b/README.md index c914253..31ffd89 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ | |---|---|---| | Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ | -| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` | +| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว | ## ขั้นตอน Setup จากศูนย์ @@ -31,16 +31,17 @@ npm install > postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ -### 2. รัน PostgreSQL + MinIO ด้วย docker compose +### 2. รัน PostgreSQL container ```bash -docker compose up -d +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 ``` -- `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` ไม่หาย +ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย ### 3. สร้างไฟล์ `.env` @@ -49,12 +50,6 @@ 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`) @@ -130,28 +125,6 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso "http://localhost:3000/docedit/api/note/external/documents" ``` -## API จัด version เอกสาร (docver — stand-alone) - -กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md` - -```bash -# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก) -curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \ - -F "userId=u0001" -F "userName=ผู้พิพากษา ก." \ - "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions" -# → {"hasPdf":true,"versionId":"","versionNumber":1} (versionNumber เป็นค่าจาก server) - -# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404 -curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions" - -# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404) -curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions//download-link?format=pdf" -``` - -- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ -- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server) -- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้ - ## Troubleshooting | อาการ | สาเหตุ/ทางแก้ | @@ -159,8 +132,7 @@ curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/ 0 ? Math.floor(ttlRaw) : 3600; - - const accessToken = issueDocverToken(parsed.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" } }, - ); -} diff --git a/app/api/documents/versions/[versionId]/download-link/route.ts b/app/api/documents/versions/[versionId]/download-link/route.ts deleted file mode 100644 index b97ce00..0000000 --- a/app/api/documents/versions/[versionId]/download-link/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -// 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); - } -} diff --git a/app/api/documents/versions/route.ts b/app/api/documents/versions/route.ts deleted file mode 100644 index 79dcc2b..0000000 --- a/app/api/documents/versions/route.ts +++ /dev/null @@ -1,170 +0,0 @@ -// 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); - } -} diff --git a/app/api/note/external/documents/route.ts b/app/api/note/external/documents/route.ts index 8a4faff..3d9f4a1 100644 --- a/app/api/note/external/documents/route.ts +++ b/app/api/note/external/documents/route.ts @@ -4,7 +4,6 @@ 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) @@ -20,8 +19,21 @@ import { requestOrigin } from "@/utils/request-origin"; const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; -// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม -// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16) +/** 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}`; +} export async function POST(req: Request) { const unauthorized = requireExternalApiKey(req); diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts deleted file mode 100644 index 13f8725..0000000 --- a/app/api/template/download-docx/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -// ตัวอย่างรองรับค่าจาก 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> -{ - // 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) -} \ No newline at end of file diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index 12108b7..dc5b49c 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; -// export const runtime = "nodejs"; +import { extractContentControls } from "../lib-tmpl"; + +export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -14,14 +14,24 @@ 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) || - (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") ) + 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") ) { return NextResponse.json( { + success: false, error: 'Field "file" must be a .docx file.', }, { status: 400 }, @@ -31,6 +41,7 @@ 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 }, @@ -41,36 +52,18 @@ export async function POST(request: Request) { const buffer = Buffer.from(await file.arrayBuffer()); // ใช้ lib-tmpl.ts เดิม - // const data = extractContentControls(buffer); + const data = extractContentControls(buffer); - const cc = extractContentControls(buffer); - const bookmark = extractBookmark(buffer) - const properties = getDocProps(buffer) - let verify: Record ={} - const ticket_data = properties?.custom?.ticket_data - const HmacSha256 = String(properties?.custom?.HmacSha256) - const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data && typeof ticket_data === 'string'){ - const ticketDataObj= JSON.parse(ticket_data) - const unix_timestamp = Number(ticketDataObj.exp)*1000 - if(unix_timestamp){ - verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }) - } - if(TEST_SECRET && HmacSha256){ - verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) - } - if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) - } - } - - let returnData : Record = {cc,bookmark,properties,verify} - return NextResponse.json(returnData); + return NextResponse.json({ + success: true, + data, + }); } catch (error) { console.error(error); + return NextResponse.json( { + success: false, error: error instanceof Error ? error.message diff --git a/app/api/template/generate/route.ts b/app/api/template/generate/route.ts deleted file mode 100644 index 1a057ee..0000000 --- a/app/api/template/generate/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -// สร้าง 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 } - ); - } -} \ No newline at end of file diff --git a/app/api/template/lib-tmpl.ts b/app/api/template/lib-tmpl.ts new file mode 100644 index 0000000..2c15f52 --- /dev/null +++ b/app/api/template/lib-tmpl.ts @@ -0,0 +1,518 @@ +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; + +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", + }); +} diff --git a/app/api/template/render/route.ts b/app/api/template/render/route.ts index c738afe..df6501c 100644 --- a/app/api/template/render/route.ts +++ b/app/api/template/render/route.ts @@ -1,71 +1,94 @@ import { NextResponse } from "next/server"; -import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; -export const runtime = "nodejs"; +import { + templateContentControls, + type TemplateData, +} from "../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") && - template.size > MAX_UPLOAD_BYTES + if ( + template.type !== DOCX_CONTENT_TYPE && + !template.name.toLowerCase().endsWith(".docx") ) { return NextResponse.json( { - error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.', + success: false, + error: 'Field "template" must be a .docx file.', }, { 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 templateData: DocumentTemplate; + // ----------------------------- + + let data: TemplateData; + // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) try { if (typeof dataJson === "string" && dataJson.length > 0) { // กรณีส่ง JSON ผ่าน form field - templateData = JSON.parse(dataJson) as DocumentTemplate; + data = JSON.parse(dataJson) as TemplateData; } 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(); - templateData = JSON.parse(text) as DocumentTemplate; + data = JSON.parse(text) as TemplateData; } else { return NextResponse.json( { + success: false, error: 'Missing JSON data. Send either "data" file or "dataJson" field.', }, @@ -75,20 +98,24 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { - error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" - }).`, + success: false, + error: `Invalid JSON data (${ + error instanceof Error ? error.message : "parse error" + }).`, }, { status: 400 }, ); } + // JSON ต้องเป็น object if ( - templateData === null || - typeof templateData !== "object" || - Array.isArray(templateData) + data === null || + typeof data !== "object" || + Array.isArray(data) ) { return NextResponse.json( { + success: false, error: "JSON data must be an object.", }, { status: 400 }, @@ -99,21 +126,31 @@ export async function POST(request: Request) { // Template File → Buffer // ----------------------------- - let templateBuffer: Buffer = Buffer.from( + const templateBuffer = Buffer.from( await template.arrayBuffer(), ); - const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC); + + // ----------------------------- + // ใช้ lib-tmpl.ts เดิม + // ----------------------------- + + const output = templateContentControls( + templateBuffer, + data, + ); // ----------------------------- // ส่ง 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), }, }); @@ -122,7 +159,7 @@ export async function POST(request: Request) { return NextResponse.json( { - + success: false, error: error instanceof Error ? error.message @@ -131,5 +168,4 @@ export async function POST(request: Request) { { status: 500 }, ); } -} - +} \ No newline at end of file diff --git a/app/api/template/test/T2.json b/app/api/template/test/T2.json new file mode 100644 index 0000000..799c36f --- /dev/null +++ b/app/api/template/test/T2.json @@ -0,0 +1,29 @@ +{ + "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": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี" +} \ No newline at end of file diff --git a/public/tmpl/T12_X.docx b/app/api/template/test/T3.docx old mode 100755 new mode 100644 similarity index 75% rename from public/tmpl/T12_X.docx rename to app/api/template/test/T3.docx index 93478ec..7573a45 Binary files a/public/tmpl/T12_X.docx and b/app/api/template/test/T3.docx differ diff --git a/app/api/template/test/make-protected.mjs b/app/api/template/test/make-protected.mjs new file mode 100644 index 0000000..c35f2b2 --- /dev/null +++ b/app/api/template/test/make-protected.mjs @@ -0,0 +1,298 @@ +// ทดลอง: เปิด 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("\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( + `` + + ``, + ); + } + + // 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(/]*\/>/)?.[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(/[\s\S]*?<\/w:sdtPr>/g) ?? []; + const plain = sdtPrs.filter((p) => / / `${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); diff --git a/app/api/template/test/note.md b/app/api/template/test/note.md new file mode 100644 index 0000000..919190b --- /dev/null +++ b/app/api/template/test/note.md @@ -0,0 +1,127 @@ +# 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` → `` +- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่ +- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป +- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `` … ``** — คือกลไก "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` เพิ่ม `` + 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 คง `` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้) + +## extract บอก alignment รายช่อง — key `@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)** + +- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม +- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `@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: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) diff --git a/public/tmpl/T_X.docx b/app/api/template/test/output-T3.docx old mode 100755 new mode 100644 similarity index 73% rename from public/tmpl/T_X.docx rename to app/api/template/test/output-T3.docx index 06182a4..c7619be Binary files a/public/tmpl/T_X.docx and b/app/api/template/test/output-T3.docx differ diff --git a/app/api/template/test/output.docx b/app/api/template/test/output.docx new file mode 100644 index 0000000..2622682 Binary files /dev/null and b/app/api/template/test/output.docx differ diff --git a/app/api/template/test/test.http b/app/api/template/test/test.http new file mode 100644 index 0000000..d615855 --- /dev/null +++ b/app/api/template/test/test.http @@ -0,0 +1,43 @@ +@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-- diff --git a/app/page.tsx b/app/page.tsx index 5169a2d..cd22e59 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,33 +1,23 @@ 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() จึงต้องส่งต่อเอง +// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy): พาเข้าหน้ารายการของ /note เสมอ +// — redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อ +// เอง ไม่งั้นสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId= (N8) พัง // (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12) -const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const; - export default function RootPage({ searchParams, }: { searchParams: Record; }) { - 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) { + for (const [key, value] of Object.entries(searchParams)) { 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()}`); + const query = qs.toString(); + redirect(`/note${query ? `?${query}` : ""}`); } diff --git a/app/tmpl/page.tsx b/app/tmpl/page.tsx deleted file mode 100644 index 0eaec44..0000000 --- a/app/tmpl/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// 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)=>{ - // console.log(e.target) - return true; - } - return ( -
-
-

ตัวอย่างการใช้ สร้างเอกสารจาก Template

-
- ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET) - เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ - ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx -
-
-
-

Form POST

- -
-
- uid: -
-
- uname: -
- - -
-
-
-

Template & Data

- Template: T_X.docx,   - T12_X.docx
- Data: TF_no_warning_P2.json,   - T_no_warning.json,   - TS_P2_IN.json
- T12_b2_IN.json,   - T12_FD155_3s-long.json -
-
-

Flow

-
- หน้านี้(เลือกไฟล์)
- ↓
- download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)
- ↓
- generate(เปิดเทมเพลต,สร้าง docx)
- ↓
- ดาว์นโหลด docx เปิด MS Word อัตโนมัติ
- ↓
- Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx
- ↓
- Versioning(คุมเวอร์ชั่น)
- ↓
- ecourt(อัปเดตฐานข้อมูลและไฟล์)
- -
- -
- -
-
- ); -} \ No newline at end of file diff --git a/app/wordaddin/layout.tsx b/app/wordaddin/layout.tsx deleted file mode 100644 index 54a73c7..0000000 --- a/app/wordaddin/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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; -} diff --git a/app/wordaddin/manifest.xml/route.ts b/app/wordaddin/manifest.xml/route.ts deleted file mode 100644 index 9b19dbd..0000000 --- a/app/wordaddin/manifest.xml/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -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", - }, - }); -} diff --git a/app/wordaddin/page.tsx b/app/wordaddin/page.tsx deleted file mode 100644 index b3509a3..0000000 --- a/app/wordaddin/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -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 ; -} diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx deleted file mode 100644 index 7331bd9..0000000 --- a/components/utils/DependentDropdown.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'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(''); - const [subSelection, setSubSelection] = useState(''); - - const handleMainChange = (e: ChangeEvent) => { - const value = e.target.value; - setMainSelection(value); - setSubSelection(''); - }; - - const handleSubChange = (e: ChangeEvent) => { - setSubSelection(e.target.value); - }; - - useEffect(() => { - if (onSelectChange) { - onSelectChange(mainSelection, subSelection); - } - }, [mainSelection, subSelection, onSelectChange]); - - const subOptions = mainSelection ? data[mainSelection] : []; - - return ( -
- {/* Dropdown ที่ 1 */} -
- - -
- - {/* Dropdown ที่ 2 */} -
- - -
-
- ); -} diff --git a/components/wordaddin/ConfirmVersionDialog.tsx b/components/wordaddin/ConfirmVersionDialog.tsx deleted file mode 100644 index e22e905..0000000 --- a/components/wordaddin/ConfirmVersionDialog.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"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(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( - "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( -
!busy && onDismiss()} - > -
event.stopPropagation()} - > -

{title ?? DEFAULT_TITLE}

-

{description ?? DEFAULT_DESCRIPTION}

- -