Compare commits

..

No commits in common. "dev" and "v1.0.21" have entirely different histories.
dev ... v1.0.21

132 changed files with 406 additions and 27068 deletions

View file

@ -19,7 +19,7 @@ paths:
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/note/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/note/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/note")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
- API ภายนอก (N10 — `app/api/note/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
- API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)

View file

@ -1,7 +1,7 @@
---
paths:
- "components/pdf/**"
- "app/note/annotate/**"
- "app/annotate/**"
---
# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)
@ -12,12 +12,12 @@ paths:
- Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px
- Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak)
- Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง
- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/note/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12)
- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12)
- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) — **hit-test ของแต่ละตัวอักษรต้องผ่านประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรอยู่ในแถบของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับแนวตั้งมากที่สุด ไม่ใช่ตัวแรกที่เจอ; แยกบรรทัดระหว่างกลุ่มด้วย "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (แทรก `\n`) ไม่ใช่ heuristic เทียบ midY กับความสูง** — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรบรรทัดถัดไป (โดยเฉพาะตัวที่สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อนหน้า**จริง 1-3px** การเช็คแค่การเหลื่อมไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป — `range.toString()` ได้ข้อความหลายบรรทัดทั้งที่ลากบรรทัดเดียว) ตัวอักษรเหล่านั้นเคยถูกดูดเข้ากลุ่มเดียวกันแล้วเรียงรวมด้วย x จนสองบรรทัดสอดแทรกตัวต่อตัวเป็น mojibake ในชื่อ bookmark/copy (bug report 2026-10-05: "แบบบันทึกผลการทดลอง…" กลายเป็น "แบบบันทึกผ(สำลกหารัรบท…") — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปอยู่นอกแถบเสมอ (เหลื่อมแค่ขอบ) จึงตัดทิ้งให้เหลือเฉพาะข้อความที่เห็นบนจอจริง ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด
- คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ — **จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่ม — map `TOOL_SHORTCUTS` ใน toolbar-tools.ts + `KeyB`/`KeyZ` ตรง ๆ) ไม่ใช่ `e.key` (2026-09-29, PLAN-TOOLBAR #1): ผู้ใช้ศาลส่วนใหญ่สลับ keyboard เป็น layout ไทย ซึ่งปุ่มเดียวกันให้ e.key เป็น "ภ"/"ว"/"แ"/"น" — เช็ค e.key ทำให้คีย์ลัดทั้งชุด (รวม Ctrl+Z undo/redo) ใช้ไม่ได้เลยบน layout ไทย**
- `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)
- Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/note/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/note/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)`
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)`
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
- Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>`
- Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)
@ -35,4 +35,4 @@ paths:
- TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด; ฟอนต์ default ตอนสร้าง = `TEXTBOX_DEFAULT_FONT_SIZE_PCT` 2.5% (≈15px — เดิม 1.2% ≈ 7px อ่านไม่ออก และเล็กกว่า preset ต่ำสุด 10px ของตัวเอง) และเข้าโหมดแก้ข้อความได้จากปุ่ม "แก้ไขข้อความ" ในแผง selection (`onStartEdit`) นอกเหนือจาก double-click (จอสัมผัส iOS double-tap = zoom หน้า dblclick จึงใช้ไม่ได้)
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0`, ตอน `openPinId !== null` (popover ของ pin เปิดอยู่) และ**เมื่อรายการที่ชี้ = `selectedId`** (แผง selection เปิดอยู่แถวนั้นแล้ว — เจอตอนลากคลุมจบแล้ว auto-select โผล่ที่ตำแหน่งเคอร์เซอร์พอดี ซ้อนเป็นสองกล่อง)
- **N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — `fileKindFromUrl` จาก `utils/file-kind.ts`):** รูป**โหลดจาก static `pdfUrl` ตรง ๆ ไม่ผ่าน `/api/note/pdf`** (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า `fixPdfActualText`) — หน้ารูป render เป็น `<img>` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม** — ทั้งไฟล์ภาพและ **PDF สแกนล้วน** (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): `hideHighlightTool` = ไฟล์ภาพ หรือ `scannedOnly` = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ `pageSpanCount` สะสมต่อหน้า ไม่ลบตอน virtualization unmount = **latch**: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ `switchTool` (**เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท**, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน `scannedOnly` กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย `closeToolMenus()` (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้
- **N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — `fileKindFromUrl` จาก `utils/file-kind.ts`):** รูป**โหลดจาก static `pdfUrl` ตรง ๆ ไม่ผ่าน `/api/pdf`** (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า `fixPdfActualText`) — หน้ารูป render เป็น `<img>` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม** — ทั้งไฟล์ภาพและ **PDF สแกนล้วน** (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): `hideHighlightTool` = ไฟล์ภาพ หรือ `scannedOnly` = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ `pageSpanCount` สะสมต่อหน้า ไม่ลบตอน virtualization unmount = **latch**: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ `switchTool` (**เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท**, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน `scannedOnly` กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย `closeToolMenus()` (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้

View file

@ -7,7 +7,7 @@ paths:
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case
- Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh

View file

@ -7,7 +7,7 @@ description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + see
## ขั้นตอน
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
## ข้อควรระวัง

View file

@ -9,4 +9,4 @@ npm-debug.log*
.env.*
!.env.example
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
public/note/uploads
public/uploads

View file

@ -5,25 +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=
# 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

11
.gitignore vendored
View file

@ -29,16 +29,7 @@ next-env.d.ts
# Claude Code
.claude/settings.local.json
# 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/
public/uploads/
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
docker-compose.test.yml
certificates

File diff suppressed because one or more lines are too long

View file

@ -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,30 +31,25 @@ 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`
```env
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
# N10 (optional): API key ของ /api/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`)
@ -67,13 +62,13 @@ npx drizzle-kit push
### 5. Seed ไฟล์ตัวอย่าง
ไฟล์ตัวอย่างอยู่ที่ `public/note/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
```bash
npm run seed-samples
```
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/note/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
### 6. รัน dev server
@ -81,7 +76,7 @@ npm run seed-samples
npm run dev
```
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`)
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
## การใช้งานบนหน้า annotate
@ -99,10 +94,10 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
## API สำหรับระบบภายนอก (N10)
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/note/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
```jsonc
// POST /docedit/api/note/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
// headers: x-api-key + content-type: application/json
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
@ -123,35 +118,13 @@ Read-only API ให้ **backend ของ e-Court** เรียกแบบ s
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
```
## API จัด version เอกสาร (docver — stand-alone)
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
```bash
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
```
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
## Troubleshooting
| อาการ | สาเหตุ/ทางแก้ |
@ -159,13 +132,12 @@ curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versi
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
## หมายเหตุทางเทคนิค
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/note/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
@ -176,18 +148,15 @@ curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versi
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
db/index.ts postgres.js + drizzle (prepare: false)
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
utils/user.ts parse/build cookie oacCase (mock login e-Court)
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
components/sidebar/ AnnotationSidebar
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
public/note/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
```

View file

@ -445,8 +445,8 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
*/
function revalidateFilePages(fileId: string) {
revalidatePath(`/note/annotate/${fileId}`);
revalidatePath(`/note/annotate/${fileId}/summary`);
revalidatePath(`/annotate/${fileId}`);
revalidatePath(`/annotate/${fileId}/summary`);
}
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {

View file

@ -36,6 +36,6 @@ export async function logout(): Promise<void> {
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้ารายการที่เพิ่งเสีย session จน navigation ไม่เกิด)
redirect("/note/login");
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
redirect("/login");
}

View file

@ -1,8 +1,8 @@
"use server";
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
// เขียนไฟล์**ใหม่**ที่ public/note/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก PLAN-FEAT.md §7 N4)
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
@ -16,7 +16,7 @@ import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
import { decodeUploadFilename } from "@/utils/upload-name";
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
const UPLOAD_DIR = path.join(process.cwd(), "public", "note", "uploads");
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
export type UploadFileResult =
| { ok: true; fileId: string; title: string }
@ -108,6 +108,6 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
throw err;
}
revalidatePath("/note");
revalidatePath("/");
return { ok: true, fileId: id, title };
}

View file

@ -30,7 +30,7 @@ export default async function AnnotatePage({
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
const user = getSessionUser();
if (!user) redirect("/note/login");
if (!user) redirect("/login");
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();

View file

@ -16,7 +16,7 @@ import {
} from "@/db/schema";
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
@ -134,7 +134,7 @@ export default async function SummaryPage({
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
<Link
href={`/note/annotate/${id}`}
href={`/annotate/${id}`}
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
>
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
@ -154,7 +154,7 @@ export default async function SummaryPage({
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
</p>
<Link
href={`/note/annotate/${id}`}
href={`/annotate/${id}`}
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow ${RING_CLS}`}
>
เปิดหน้าอ่าน

View file

@ -7,9 +7,9 @@ import { firstSearchParam } from "@/utils/external-file";
export const dynamic = "force-dynamic";
/**
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/annotate?fileUrl=&fileId=&typeId=`
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/annotate/<id>` ของตัว viewer จริง
* (entry เดิมที่หน้า "/" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /?fileUrl= และ /annotate?fileUrl=)
*/
export default async function AnnotateEntryPage({
searchParams,
@ -24,7 +24,7 @@ export default async function AnnotateEntryPage({
firstSearchParam(searchParams.typeId),
);
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
if (result.ok) redirect(`/annotate/${result.fileId}`);
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
@ -34,7 +34,7 @@ export default async function AnnotateEntryPage({
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
</div>
<Link
href="/note"
href="/"
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
>
กลับหน้ารายการ
@ -43,7 +43,7 @@ export default async function AnnotateEntryPage({
);
}
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
@ -53,7 +53,7 @@ export default async function AnnotateEntryPage({
หรือเลือกไฟล์จากหน้ารายการ
</div>
<Link
href="/note"
href="/"
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
>
กลับหน้ารายการ

View file

@ -1,51 +0,0 @@
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่
// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
import { docverError } from "@/lib/document-versioning";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocverApiKey,
} from "@/lib/docver-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail;
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseTokenRequestBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
const ttlSeconds =
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
const accessToken = issueDocverToken(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" } },
);
}

View file

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

View file

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

View file

@ -4,9 +4,8 @@ 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
// N10 (PLAN-API.md D2) — POST /api/external/documents
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
@ -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);

View file

@ -1,44 +0,0 @@
import { NextResponse } from "next/server";
import { openApiSpec } from "@/lib/openapi";
import { withBasePath } from "@/utils/base-path";
// GET /api/swagger — หน้า Swagger UI ของ API ทั้งหมด (กลุ่ม template + N10)
// (asset สำหรับ intranet: postinstall scripts/copy-swagger-ui.mjs คัดลอก
// swagger-ui-dist ไป public/note/swagger-ui/ — pattern เดียวกับ pdf.worker)
// spec inline จาก lib/openapi.ts ตรง ๆ ไม่มี endpoint spec แยก
export const runtime = "nodejs";
// กัน </script> ใน JSON ตัดสคริปต์ขาย (spec เป็นของเราเองทั้งหมด แค่กันกรณี
// ข้อความ example มี < ในอนาคต)
const SPEC_JSON = JSON.stringify(openApiSpec).replace(/</g, "\\u003c");
const HTML = `<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swagger UI — Judge's Note API</title>
<link rel="stylesheet" href="${withBasePath("/swagger-ui/swagger-ui.css")}">
</head>
<body>
<div id="swagger-ui"></div>
<script src="${withBasePath("/swagger-ui/swagger-ui-bundle.js")}"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
spec: ${SPEC_JSON},
dom_id: "#swagger-ui",
deepLinking: true,
persistAuthorization: true
});
};
</script>
</body>
</html>`;
export async function GET() {
return new NextResponse(HTML, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}

View file

@ -1,102 +0,0 @@
import { NextResponse } from "next/server";
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
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
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const TEST_SECRET = String(formData.get("secret"))??"";
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??"";
if (!(file instanceof File)) {
return NextResponse.json(
{
success: false,
error: 'Missing DOCX file in field "file".',
},
{ status: 400 },
);
}
if (
file.type !== DOCX_CONTENT_TYPE &&
!file.name.toLowerCase().endsWith(".docx")
) {
return NextResponse.json(
{
success: false,
error: 'Field "file" must be a .docx file.',
},
{ status: 400 },
);
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
success: false,
error: 'Field "file" exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// File → Buffer
const buffer = Buffer.from(await file.arrayBuffer());
// ใช้ lib-tmpl.ts เดิม
// const data = extractContentControls(buffer);
const cc = extractContentControls(buffer);
const bookmark = extractBookmark(buffer)
const properties = getDocProps(buffer)
let verify: Record<string, unknown> ={}
const ticket_data = String(properties?.custom?.ticket_data)
const HmacSha256 = String(properties?.custom?.HmacSha256)
const Ed25519 = String(properties?.custom?.Ed25519)
if(ticket_data){
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['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
}
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
}
}
let returnData : Record<string, unknown> = {
success: true,
cc,bookmark,properties,verify
}
return NextResponse.json(returnData);
} catch (error) {
console.error(error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: "Internal Server Error",
},
{ status: 500 },
);
}
}

View file

@ -1,518 +0,0 @@
import PizzaZip from "pizzip";
import {
DOMParser,
XMLSerializer,
type Document as XmlDocument,
type Element as XmlElement,
type Node as XmlNode,
type Attr as XmlAttr,
} from "@xmldom/xmldom";
import xpath from "xpath";
export interface ExtractedData {
[tagName: string]: string;
}
const W_NS =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const asXpathNode = (node: XmlNode): globalThis.Node =>
node as unknown as globalThis.Node;
function createSelector() {
return xpath.useNamespaces({ w: W_NS });
}
/**
* อ่าน Content Control (w:sdt) จาก DOCX Buffer
* ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ
*/
export function extractContentControls(
docxBuffer: Buffer,
): ExtractedData {
const zip = new PizzaZip(docxBuffer);
const docXmlFile = zip.file("word/document.xml");
if (!docXmlFile) {
throw new Error("Invalid docx file: word/document.xml not found.");
}
const parser = new DOMParser();
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
const select = createSelector();
const result: ExtractedData = {};
const sdtNodes = select(
"//w:sdt",
asXpathNode(doc),
) as unknown as XmlElement[];
sdtNodes.forEach((sdt) => {
const tagAttr = select(
"./w:sdtPr/w:tag/@w:val",
asXpathNode(sdt),
true,
) as unknown as XmlAttr | undefined;
const tagName = tagAttr?.value;
if (!tagName) return;
const sdtContent = select(
"./w:sdtContent",
asXpathNode(sdt),
true,
) as unknown as XmlElement | undefined;
if (!sdtContent) {
result[tagName] = "";
return;
}
const nodes = select(
".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym",
asXpathNode(sdtContent),
) as unknown as XmlElement[];
let extractedText = "";
nodes.forEach((node) => {
switch (node.nodeName) {
case "w:p":
if (
extractedText.length > 0 &&
!extractedText.endsWith("\n")
) {
extractedText += "\n";
}
break;
case "w:t":
extractedText += node.textContent || "";
break;
case "w:tab":
extractedText += "\t";
break;
case "w:br":
extractedText += "\n";
break;
case "w:noBreakHyphen":
extractedText += "\u2011";
break;
case "w:softHyphen":
extractedText += "\u00ad";
break;
case "w:sym": {
const charAttr = node.getAttribute("w:char");
if (charAttr) {
const charCode = Number.parseInt(charAttr, 16);
extractedText += String.fromCharCode(charCode);
}
break;
}
}
});
result[tagName] = extractedText;
});
return result;
}
type SdtControlType = "plain" | "rich" | "other";
type SdtStructure = "block" | "inline";
function detectSdtControlType(
sdt: XmlElement,
select: xpath.XPathSelect,
): { type: SdtControlType; multiLine: boolean } {
const sdtPr = select(
"./w:sdtPr",
asXpathNode(sdt),
true,
) as unknown as XmlElement | undefined;
if (!sdtPr) {
return { type: "other", multiLine: false };
}
const textEl = select(
"./w:text",
asXpathNode(sdtPr),
true,
) as unknown as XmlElement | undefined;
if (textEl) {
const multiLineAttr = select(
"./@w:multiLine",
asXpathNode(textEl),
true,
) as unknown as XmlAttr | undefined;
return {
type: "plain",
multiLine:
multiLineAttr?.value === "1" ||
multiLineAttr?.value === "true",
};
}
const richTextEl = select(
"./w:richText",
asXpathNode(sdtPr),
true,
) as unknown as XmlElement | undefined;
if (richTextEl) {
return { type: "rich", multiLine: true };
}
const otherTypeTags = [
"w:date",
"w:dropDownList",
"w:comboBox",
"w:checkbox",
"w:citation",
"w:group",
"w:docPartObj",
"w:docPartList",
];
for (const tag of otherTypeTags) {
const found = select(
`./${tag}`,
asXpathNode(sdtPr),
true,
) as unknown as XmlElement | undefined;
if (found) {
return { type: "other", multiLine: false };
}
}
return { type: "rich", multiLine: true };
}
function detectSdtStructure(
sdtContent: XmlElement,
select: xpath.XPathSelect,
): SdtStructure {
const directP = select(
"./w:p",
asXpathNode(sdtContent),
) as unknown as XmlElement[];
return directP.length > 0 ? "block" : "inline";
}
function buildRunChildren(
doc: XmlDocument,
text: string,
): XmlElement[] {
const nodes: XmlElement[] = [];
const tokens = text.split(/(\t|\n)/);
tokens.forEach((token) => {
if (token === "") return;
if (token === "\t") {
nodes.push(doc.createElementNS(W_NS, "w:tab"));
} else if (token === "\n") {
nodes.push(doc.createElementNS(W_NS, "w:br"));
} else {
const t = doc.createElementNS(W_NS, "w:t");
t.setAttribute("xml:space", "preserve");
t.textContent = token;
nodes.push(t);
}
});
if (nodes.length === 0) {
const t = doc.createElementNS(W_NS, "w:t");
t.setAttribute("xml:space", "preserve");
t.textContent = "";
nodes.push(t);
}
return nodes;
}
function buildRun(
doc: XmlDocument,
text: string,
rPr?: XmlElement,
): XmlElement {
const run = doc.createElementNS(W_NS, "w:r");
if (rPr) {
run.appendChild(rPr.cloneNode(true));
}
buildRunChildren(doc, text).forEach((node) =>
run.appendChild(node),
);
return run;
}
function insertInlineContent(
doc: XmlDocument,
sdtContent: XmlElement,
select: xpath.XPathSelect,
text: string,
allowLineBreak: boolean,
): void {
const safeText = allowLineBreak
? text
: text.replace(/\n/g, " ");
const existingRuns = select(
"./w:r",
asXpathNode(sdtContent),
) as unknown as XmlElement[];
const firstRPr =
existingRuns.length > 0
? (select(
"./w:rPr",
asXpathNode(existingRuns[0]),
true,
) as unknown as XmlElement | undefined)
: undefined;
existingRuns.forEach((r) => {
if (r.parentNode) r.parentNode.removeChild(r);
});
sdtContent.appendChild(
buildRun(doc, safeText, firstRPr),
);
}
function insertBlockContent(
doc: XmlDocument,
sdtContent: XmlElement,
select: xpath.XPathSelect,
text: string,
allowMultiParagraph: boolean,
): void {
const allParagraphs = select(
"./w:p",
asXpathNode(sdtContent),
) as unknown as XmlElement[];
if (allParagraphs.length === 0) {
// ป้องกัน template ที่มีโครงสร้างผิดปกติ
const p = doc.createElementNS(W_NS, "w:p");
sdtContent.appendChild(p);
allParagraphs.push(p);
}
const firstP = allParagraphs[0];
const firstPPr = select(
"./w:pPr",
asXpathNode(firstP),
true,
) as unknown as XmlElement | undefined;
const firstRun = select(
"./w:r",
asXpathNode(firstP),
true,
) as unknown as XmlElement | undefined;
const firstRPr = firstRun
? (select(
"./w:rPr",
asXpathNode(firstRun),
true,
) as unknown as XmlElement | undefined)
: undefined;
allParagraphs.forEach((p) => {
if (p !== firstP && p.parentNode) {
p.parentNode.removeChild(p);
}
});
const existingRuns = select(
"./w:r",
asXpathNode(firstP),
) as unknown as XmlElement[];
existingRuns.forEach((r) => {
if (r.parentNode) r.parentNode.removeChild(r);
});
if (!allowMultiParagraph) {
firstP.appendChild(buildRun(doc, text, firstRPr));
return;
}
text.split("\n").forEach((line, index) => {
let targetP: XmlElement;
if (index === 0) {
targetP = firstP;
} else {
targetP = doc.createElementNS(W_NS, "w:p");
if (firstPPr) {
targetP.appendChild(firstPPr.cloneNode(true));
}
sdtContent.appendChild(targetP);
}
const run = doc.createElementNS(W_NS, "w:r");
if (firstRPr) {
run.appendChild(firstRPr.cloneNode(true));
}
buildRunChildren(doc, line).forEach((node) =>
run.appendChild(node),
);
targetP.appendChild(run);
});
}
function insertFallbackContent(
sdtContent: XmlElement,
select: xpath.XPathSelect,
text: string,
): void {
const textNodes = select(
".//w:t",
asXpathNode(sdtContent),
) as unknown as XmlElement[];
if (textNodes.length === 0) return;
textNodes[0].textContent = text;
for (let i = 1; i < textNodes.length; i++) {
textNodes[i].textContent = "";
}
}
export type TemplateData = Record<string, unknown>;
function normalizeValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
// รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text
return JSON.stringify(value);
}
/**
* นำ JSON data ไปแทนค่า Content Control ใน DOCX template
* คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว
*/
export function templateContentControls(
templateBuffer: Buffer,
data: TemplateData,
): Buffer {
const zip = new PizzaZip(templateBuffer);
const docXmlFile = zip.file("word/document.xml");
if (!docXmlFile) {
throw new Error("Invalid docx file: word/document.xml not found.");
}
const parser = new DOMParser();
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
const select = createSelector();
const sdtNodes = select(
"//w:sdt",
asXpathNode(doc),
) as unknown as XmlElement[];
sdtNodes.forEach((sdt) => {
const tagAttr = select(
"./w:sdtPr/w:tag/@w:val",
asXpathNode(sdt),
true,
) as unknown as XmlAttr | undefined;
const tagName = tagAttr?.value;
if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) {
return;
}
const newValue = normalizeValue(data[tagName]);
const sdtContent = select(
"./w:sdtContent",
asXpathNode(sdt),
true,
) as unknown as XmlElement | undefined;
if (!sdtContent) return;
const { type: controlType, multiLine } =
detectSdtControlType(sdt, select);
const structure = detectSdtStructure(sdtContent, select);
if (controlType === "other") {
insertFallbackContent(sdtContent, select, newValue);
return;
}
if (structure === "inline") {
const allowLineBreak =
controlType === "plain" ? multiLine : true;
insertInlineContent(
doc,
sdtContent,
select,
newValue,
allowLineBreak,
);
} else {
insertBlockContent(
doc,
sdtContent,
select,
newValue,
controlType === "rich",
);
}
});
const serializer = new XMLSerializer();
zip.file(
"word/document.xml",
serializer.serializeToString(doc),
);
return zip.generate({
type: "nodebuffer",
compression: "DEFLATE",
});
}

View file

@ -1,208 +0,0 @@
import { NextResponse } from "next/server";
import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl";
import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils';
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
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 = (typeof formData.get("secret") === "string") ?
String(formData.get("secret")) : "super_secret";
const TEST_PRIVATE_KEY_HEX = (typeof formData.get("private_key") === "string") ?
String(formData.get("private_key")) : "8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1";
console.log(`secret:${TEST_SECRET}`)
console.log(`private:${TEST_PRIVATE_KEY_HEX}`)
// -----------------------------
// ตรวจสอบ 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")
) {
return NextResponse.json(
{
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;
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
try {
if (typeof dataJson === "string" && dataJson.length > 0) {
// กรณีส่ง JSON ผ่าน form field
templateData = JSON.parse(dataJson) as DocumentTemplate;
} else if (dataFile instanceof File) {
if (dataFile.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
success: false,
error: 'Field "data" exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// กรณี upload data.json
const text = await dataFile.text();
templateData = JSON.parse(text) as DocumentTemplate;
} else {
return NextResponse.json(
{
success: false,
error:
'Missing JSON data. Send either "data" file or "dataJson" field.',
},
{ status: 400 },
);
}
} catch (error) {
return NextResponse.json(
{
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)
) {
return NextResponse.json(
{
success: false,
error: "JSON data must be an object.",
},
{ status: 400 },
);
}
// -----------------------------
// Template File → Buffer
// -----------------------------
let templateBuffer: Buffer = Buffer.from(
await template.arrayBuffer(),
);
//Calculate hash
const originalTicket_data = templateData?.info?.ticket_data //expect valid json string
// console.log("templateData",templateData?.info?.ticket_data)
if (originalTicket_data && typeof originalTicket_data === 'string') {
try {
const ttlSeconds: number = 300
const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data
if (originalTicketObj && typeof originalTicketObj === 'object') {
console.log("Found valid ticket data: hash it")
// คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ)
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
const expireTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds;
const fullObj = { ...originalTicketObj, exp: expireTimestamp };
const sortedObj = sortObjectKeys(fullObj);
// แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash
const ticket_data = JSON.stringify(sortedObj);
// console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data)
const HmacSha256 = generateHmacSha256(ticket_data, TEST_SECRET)
const Ed25519 = signDataEd25519(ticket_data, TEST_PRIVATE_KEY_HEX)
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
}
} catch (error) {
console.error("Set custom property fail",error);
}
}
// Apply Dynamic Content
if (templateData?.dc && typeof templateData?.dc === "object") {
console.log("Apply Dynamic Content")
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
}
// Apply Content Control
if (templateData?.cc && typeof templateData?.cc === "object") {
console.log("Apply Content Control")
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
}
const output = templateBuffer;
// -----------------------------
// ส่ง DOCX กลับ
// -----------------------------
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),
},
});
} catch (error) {
console.error(error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: "Internal Server Error",
},
{ status: 500 },
);
}
}

View file

@ -1,29 +0,0 @@
{
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
"nottype": "(ต. ๑)",
"blackno": "คดีหมายเลขดำที่",
"blackyear": "๑๐/๒๕๖๙",
"redno": "คดีหมายเลขแดงที่",
"redyear": "",
"court": "ศาลปกครองสุพรรณบุรี",
"curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
"accuser": "นายธัญวิสิษฐ์ ศรแก้ว",
"acc1": "ผู้ฟ้องคดี",
"accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน",
"acc2": "ผู้ถูกฟ้องคดี",
"nottitle": "คำสั่งถึง",
"commto": "เจ้าคุณมด",
"groupname": "ผู้ถูกฟ้องคดีที่ ๑",
"detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้",
"pname": "นางนพเก้า เถื่อนชัย",
"courtname": "ศาลปกครองสุพรรณบุรี",
"courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”",
"sendto": "เจ้าคุณมด",
"addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -",
"remarkhead": "",
"remark": "",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
}

Binary file not shown.

Binary file not shown.

View file

@ -1,298 +0,0 @@
// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties
// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md
//
// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด)
//
// สิ่งที่แก้ในไฟล์ docx:
// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1"
// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย
// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้)
// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย
// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone"
// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค)
// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้
// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr
// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml
// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml
import PizzaZip from "pizzip";
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import xpath from "xpath";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx");
const outPath = process.argv[3] ?? inPath;
// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร)
const CUSTOM_PROPS = {
token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo",
"primary-key": "DOC-2026-000123",
owner: "u1001",
};
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const CUSTOM_NS =
"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
const VT_NS =
"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
const REL_CUSTOM =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
const CT_CUSTOM =
"application/vnd.openxmlformats-officedocument.custom-properties+xml";
const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
const parser = new DOMParser();
const serializer = new XMLSerializer();
const parseXml = (text) => parser.parseFromString(text, "text/xml");
// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี
// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid)
const serialize = (doc) => {
const text = serializer.serializeToString(doc);
return text.startsWith("<?xml")
? text
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + text;
};
// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone")
// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ
function unlockContentControls(zip) {
const select = xpath.useNamespaces({ w: W_NS });
const asNode = (n) => n;
const parts = Object.keys(zip.files).filter((n) =>
/^word\/(document|header\d*|footer\d*)\.xml$/.test(n),
);
// id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part)
let nextId = 999;
for (const part of parts) {
const ids = select(
"//w:permStart/@w:id | //w:permEnd/@w:id",
asNode(parseXml(zip.file(part).asText())),
);
for (const id of ids) {
const n = Number.parseInt(id.value, 10);
if (Number.isFinite(n) && n >= nextId) nextId = n;
}
}
let wrapped = 0;
let locked = 0;
for (const part of parts) {
const doc = parseXml(zip.file(part).asText());
// เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template
// และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ)
const sdts = select(
"//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]",
asNode(doc),
);
if (sdts.length === 0) continue;
for (const sdt of sdts) {
// เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission
const sdtPr = select("./w:sdtPr", sdt)[0];
const hasLock = select("./w:lock", sdtPr).length > 0;
if (!hasLock) {
const lock = doc.createElementNS(W_NS, "w:lock");
lock.setAttribute("w:val", "sdtLocked");
// w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr
const idEl = select("./w:id", sdtPr)[0];
if (idEl && idEl.nextSibling) {
sdtPr.insertBefore(lock, idEl.nextSibling);
} else {
sdtPr.appendChild(lock);
}
locked++;
}
const start = doc.createElementNS(W_NS, "w:permStart");
start.setAttribute("w:id", String(nextId));
start.setAttribute("w:edGrp", "everyone");
const end = doc.createElementNS(W_NS, "w:permEnd");
end.setAttribute("w:id", String(nextId));
nextId++;
sdt.parentNode.insertBefore(start, sdt);
sdt.parentNode.insertBefore(end, sdt.nextSibling);
wrapped++;
}
zip.file(part, serialize(doc));
}
return { wrapped, locked };
}
function enableReadOnlyProtection(zip) {
const file = zip.file("word/settings.xml");
if (!file) throw new Error("word/settings.xml not found");
const doc = parseXml(file.asText());
const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection");
const prot = prots.length > 0 ? prots[0] : null;
if (prot) {
prot.setAttribute("w:edit", "readOnly");
prot.setAttribute("w:enforcement", "1");
} else {
// T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings
const el = doc.createElementNS(W_NS, "w:documentProtection");
el.setAttribute("w:edit", "readOnly");
el.setAttribute("w:enforcement", "1");
doc.documentElement.insertBefore(el, doc.documentElement.firstChild);
}
zip.file("word/settings.xml", serialize(doc));
}
function setCustomProperties(zip, props) {
// --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม ---
const existing = zip.file("docProps/custom.xml");
let doc;
if (existing) {
doc = parseXml(existing.asText());
// ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่)
const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property"));
for (const p of all) {
if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) {
p.parentNode.removeChild(p);
}
}
} else {
doc = parseXml(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
);
}
// pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1
const usedPids = Array.from(
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1);
let nextPid = Math.max(1, ...usedPids) + 1;
for (const [name, value] of Object.entries(props)) {
const prop = doc.createElementNS(CUSTOM_NS, "property");
prop.setAttribute("fmtid", FMTID);
prop.setAttribute("pid", String(nextPid++));
prop.setAttribute("name", name);
const val = doc.createElementNS(VT_NS, "vt:lpwstr");
val.textContent = value;
prop.appendChild(val);
doc.documentElement.appendChild(prop);
}
zip.file("docProps/custom.xml", serialize(doc));
// --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) ---
const ct = parseXml(zip.file("[Content_Types].xml").asText());
const hasCt = Array.from(ct.getElementsByTagName("Override")).some(
(o) => o.getAttribute("PartName") === "/docProps/custom.xml",
);
if (!hasCt) {
const el = ct.createElement("Override");
el.setAttribute("PartName", "/docProps/custom.xml");
el.setAttribute("ContentType", CT_CUSTOM);
ct.documentElement.appendChild(el);
zip.file("[Content_Types].xml", serialize(ct));
}
// --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) ---
const rels = parseXml(zip.file("_rels/.rels").asText());
const relNodes = Array.from(rels.getElementsByTagName("Relationship"));
if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) {
const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id")));
let n = 1;
while (usedIds.has(`rId${n}`)) n++;
const el = rels.createElement("Relationship");
el.setAttribute("Id", `rId${n}`);
el.setAttribute("Type", REL_CUSTOM);
el.setAttribute("Target", "docProps/custom.xml");
rels.documentElement.appendChild(el);
zip.file("_rels/.rels", serialize(rels));
}
}
// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์
function verify(path) {
const zip = new PizzaZip(readFileSync(path));
const settings = zip.file("word/settings.xml")?.asText() ?? "";
const prot = settings.match(/<w:documentProtection[^>]*\/>/)?.[0];
const enforced =
prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"');
const customXml = zip.file("docProps/custom.xml")?.asText();
const propsBack = {};
if (customXml) {
const doc = parseXml(customXml);
for (const p of Array.from(
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
)) {
propsBack[p.getAttribute("name")] = p.textContent;
}
}
const rels = zip.file("_rels/.rels")?.asText() ?? "";
const ct = zip.file("[Content_Types].xml")?.asText() ?? "";
const docXml = zip.file("word/document.xml")?.asText() ?? "";
const sdtPrs = docXml.match(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
const rich = sdtPrs.length - plain;
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
const missingLock = sdtPrs.filter(
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
).length;
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
console.log(
"custom properties :",
Object.entries(propsBack)
.map(([k, v]) => `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`)
.join(", ") || "(ว่าง)",
);
console.log(
`relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`,
);
console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`);
console.log(
`permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` +
(permStarts === tagged && permEnds === tagged ? "✓" : "✗"),
);
console.log(
`CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`,
);
return (
enforced &&
!!customXml &&
permStarts === tagged &&
permEnds === tagged &&
missingLock === 0
);
}
const zip = new PizzaZip(readFileSync(inPath));
enableReadOnlyProtection(zip);
const { wrapped, locked } = unlockContentControls(zip);
setCustomProperties(zip, CUSTOM_PROPS);
if (locked > 0) {
console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`);
}
console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`);
const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" });
writeFileSync(outPath, out);
console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`);
const ok = verify(outPath);
process.exit(ok ? 0 : 1);

View file

@ -1,127 +0,0 @@
# Docx Template
lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word
## วิธีการใช้ API
```sh
## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login)
http://localhost:3000/docedit/api/swagger
## template/test ไว้ทดสอบเท่านั้น
curl.exe http://localhost:3000/docedit/api/template/test
## extract ค่าออกมาจาก content control
curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx"
## render เอกสาร (docx) = template(docx)+data(json)
### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน
curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx
### data เอาจาก string
curl -X POST http://localhost:3000/docedit/api/template/render \
-F "template=@template.docx" \
-F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \
-o generated-document.docx
```
data(json) ตอนนี้อยู่ในรูปแบบ
```json
{
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
"nottype": "(ต. ๑)",
"blackno": "คดีหมายเลขดำที่",
"blackyear": "๑๐/๒๕๖๙",
...
}
```
data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration
หลักผ่าน API
- ส่วนของ cc จะไปกรอกใน Content Control
- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร
```json
{
"uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร",
"uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล",
"fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน",
"ott":"อายุของ one time token แบบระยะสั้น",
"expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)",
cc:{
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
"nottype": "(ต. ๑)",
"blackno": "คดีหมายเลขดำที่",
"blackyear": "๑๐/๒๕๖๙",
...
}
}
```
ที่ต้องทำเพิ่ม
- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต
- Backend จัดการ version
- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน
# ทดลอง Restrict Edit + Custom Properties (2026-10-07)
สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง
```sh
node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS)
```
## Restrict Edit (เปิดผ่านโค้ด)
- อยู่ที่ `word/settings.xml` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar)
- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย
- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ)
## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner)
ทางเลือกที่ศึกษา:
1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+)
- ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย:
1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr`
2. `[Content_Types].xml` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>`
3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml`
2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token
3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก
gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt
- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง
## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07)
- ค่า OOXML คือ `<w:jc w:val="thaiDistribute"/>` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์
- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ)
- วิธีจัดเองใน Word:
- รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed
- ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed
- จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด
- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render
- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`)
## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์)
- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓
- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓
- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓
- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓
- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓
- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓
## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1)
- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้
- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2)
- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password
## เหลือตรวจใน Word จริง (รอบ 2)
- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5)
- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้)
- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ
- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline)

Binary file not shown.

Binary file not shown.

View file

@ -1,10 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({
ok: true,
service: "docx-template-api",
});
}

View file

@ -1,43 +0,0 @@
@baseUrl = http://localhost:3000/docedit/api
### 1. Extract Template
POST {{baseUrl}}/template/extract
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="file"; filename="output-T3.docx"
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
< ./output-T3.docx
--WebAppBoundary--
### 2. Render Template with JSON File
POST {{baseUrl}}/template/render
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="template"; filename="T3.docx"
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
< ./T3.docx
--WebAppBoundary
Content-Disposition: form-data; name="data"; filename="T2.json"
Content-Type: application/json
< ./T2.json
--WebAppBoundary--
### 3. Render Template with Raw JSON String
POST {{baseUrl}}/template/render
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="template"; filename="template.docx"
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
< ./T3.docx
--WebAppBoundary
Content-Disposition: form-data; name="dataJson"
{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"}
--WebAppBoundary--

View file

@ -130,7 +130,7 @@ export default async function GroupPage({
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
<Link
href="/note"
href="/"
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
>
<ArrowLeft aria-hidden className="h-4 w-4" />

View file

@ -22,7 +22,7 @@ export default function LoginPage({
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
const user = getSessionUser();
if (user) redirect(next ?? "/note");
if (user) redirect(next ?? "/");
return (
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง

View file

@ -1,236 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
import { FileImage, FileText, Folder } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
import AppNav from "@/components/AppNav";
import EcourtLinkForm from "@/components/EcourtLinkForm";
import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { formatThaiDate } from "@/utils/date-format";
import { getSessionUser } from "@/lib/session";
import { registerExternalFile } from "@/lib/external-import";
import { firstSearchParam } from "@/utils/external-file";
export const dynamic = "force-dynamic";
type FileRow = typeof files.$inferSelect;
/** badge ชนิดไฟล์ต่อรายการ (N3) */
function kindBadge(pdfUrl: string): string {
switch (imageFormatFromUrl(pdfUrl)) {
case "jpeg":
return "JPG";
case "png":
return "PNG";
case "tiff":
return "TIFF";
default:
return "PDF";
}
}
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
function FileCard({ file }: { file: FileRow }) {
return (
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
<div className="flex items-start justify-between gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
{imageFormatFromUrl(file.pdfUrl) ? (
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
) : (
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
)}
</div>
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
{kindBadge(file.pdfUrl)}
</span>
</div>
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
{file.title}
</span>
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
<span className="mt-1 block text-caption text-ink-soft">
{formatThaiDate(file.createdAt, true)}
</span>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
<div className="mt-5 flex flex-1 items-end justify-end">
<Link
href={`/note/annotate/${file.id}`}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
Annotate
</Link>
</div>
</li>
);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/note/login");
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
let registerError: string | null = null;
const fileUrl = firstSearchParam(searchParams.fileUrl);
if (fileUrl) {
const result = await registerExternalFile(
fileUrl,
firstSearchParam(searchParams.fileId),
firstSearchParam(searchParams.typeId),
);
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
registerError = result.error;
}
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
const grouped = new Map<string, FileRow[]>();
const ungrouped: FileRow[] = [];
for (const f of rows) {
if (f.typeId) {
const list = grouped.get(f.typeId);
if (list) list.push(f);
else grouped.set(f.typeId, [f]);
} else {
ungrouped.push(f);
}
}
const sections = Array.from(grouped.entries()).sort(
(a, b) =>
b[1][b[1].length - 1].createdAt.getTime() -
a[1][a[1].length - 1].createdAt.getTime(),
);
return (
<>
<AppNav userName={user.userName} />
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
summary-page ของหน้าสรุป) */}
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{registerError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
</div>
)}
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
ฟอร์มอัปโหลด) */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
เปิดเอกสารจากลิงก์ e-Court
</h2>
</div>
<div className="mt-5 max-w-xl">
<EcourtLinkForm />
</div>
</section>
<section className="mt-12">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-caption tabular-nums text-ink-soft">
{rows.length} ไฟล์
</span>
)}
</div>
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
<div className="mt-5 max-w-xl">
<UploadFileForm />
</div>
{rows.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-ink-soft">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<>
{sections.map(([typeId, list]) => (
<section key={typeId} className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<Folder
aria-hidden
className="h-4 w-4 shrink-0 text-ink-soft"
/>
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
{typeId}
</h3>
<span className="text-caption tabular-nums text-ink-soft">
{list.length} ไฟล์
</span>
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
<Link
href={`/note/groups/${encodeURIComponent(typeId)}`}
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
>
เอกสารในกลุ่ม
</Link>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{list.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
))}
{ungrouped.length > 0 && (
<section className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
<span className="text-caption tabular-nums text-ink-soft">
{ungrouped.length} ไฟล์
</span>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{ungrouped.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
)}
</>
)}
</section>
</main>
</>
);
}

View file

@ -1,33 +1,236 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
import { FileImage, FileText, Folder } from "lucide-react";
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
import { db } from "@/db";
import { files } from "@/db/schema";
import AppNav from "@/components/AppNav";
import EcourtLinkForm from "@/components/EcourtLinkForm";
import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { formatThaiDate } from "@/utils/date-format";
import { getSessionUser } from "@/lib/session";
import { registerExternalFile } from "@/lib/external-import";
import { firstSearchParam } from "@/utils/external-file";
export default function RootPage({
export const dynamic = "force-dynamic";
type FileRow = typeof files.$inferSelect;
/** badge ชนิดไฟล์ต่อรายการ (N3) */
function kindBadge(pdfUrl: string): string {
switch (imageFormatFromUrl(pdfUrl)) {
case "jpeg":
return "JPG";
case "png":
return "PNG";
case "tiff":
return "TIFF";
default:
return "PDF";
}
}
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
function FileCard({ file }: { file: FileRow }) {
return (
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
<div className="flex items-start justify-between gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
{imageFormatFromUrl(file.pdfUrl) ? (
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
) : (
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
)}
</div>
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
{kindBadge(file.pdfUrl)}
</span>
</div>
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
{file.title}
</span>
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
<span className="mt-1 block text-caption text-ink-soft">
{formatThaiDate(file.createdAt, true)}
</span>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
<div className="mt-5 flex flex-1 items-end justify-end">
<Link
href={`/annotate/${file.id}`}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
Annotate
</Link>
</div>
</li>
);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
const entries = Object.entries(searchParams);
const isEcourtLink = entries.some(([key]) =>
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
);
if (!isEcourtLink) {
redirect("/wordaddin");
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/login");
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
// (entry ที่สองอยู่ที่ /annotate?fileUrl=… — app/annotate/page.tsx)
let registerError: string | null = null;
const fileUrl = firstSearchParam(searchParams.fileUrl);
if (fileUrl) {
const result = await registerExternalFile(
fileUrl,
firstSearchParam(searchParams.fileId),
firstSearchParam(searchParams.typeId),
);
if (result.ok) redirect(`/annotate/${result.fileId}`);
registerError = result.error;
}
const qs = new URLSearchParams();
for (const [key, value] of entries) {
if (typeof value === "string") {
qs.set(key, value);
} else if (Array.isArray(value)) {
for (const v of value) qs.append(key, v);
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
const grouped = new Map<string, FileRow[]>();
const ungrouped: FileRow[] = [];
for (const f of rows) {
if (f.typeId) {
const list = grouped.get(f.typeId);
if (list) list.push(f);
else grouped.set(f.typeId, [f]);
} else {
ungrouped.push(f);
}
}
redirect(`/note?${qs.toString()}`);
const sections = Array.from(grouped.entries()).sort(
(a, b) =>
b[1][b[1].length - 1].createdAt.getTime() -
a[1][a[1].length - 1].createdAt.getTime(),
);
return (
<>
<AppNav userName={user.userName} />
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
summary-page ของหน้าสรุป) */}
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{registerError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
</div>
)}
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
ฟอร์มอัปโหลด) */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
เปิดเอกสารจากลิงก์ e-Court
</h2>
</div>
<div className="mt-5 max-w-xl">
<EcourtLinkForm />
</div>
</section>
<section className="mt-12">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-caption tabular-nums text-ink-soft">
{rows.length} ไฟล์
</span>
)}
</div>
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
<div className="mt-5 max-w-xl">
<UploadFileForm />
</div>
{rows.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-ink-soft">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<>
{sections.map(([typeId, list]) => (
<section key={typeId} className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<Folder
aria-hidden
className="h-4 w-4 shrink-0 text-ink-soft"
/>
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
{typeId}
</h3>
<span className="text-caption tabular-nums text-ink-soft">
{list.length} ไฟล์
</span>
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
<Link
href={`/groups/${encodeURIComponent(typeId)}`}
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
>
เอกสารในกลุ่ม
</Link>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{list.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
))}
{ungrouped.length > 0 && (
<section className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
<span className="text-caption tabular-nums text-ink-soft">
{ungrouped.length} ไฟล์
</span>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{ungrouped.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
)}
</>
)}
</section>
</main>
</>
);
}

View file

@ -7,13 +7,13 @@ import {
} from "@/utils/file-kind";
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
// เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ

View file

@ -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;
}

View file

@ -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",
},
});
}

View file

@ -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 <WordAddinGate />;
}

View file

@ -1,6 +1,6 @@
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
// เป็นแถบของตัวเองกลางหน้า
import Link from "next/link";
@ -15,7 +15,7 @@ export default function AppNav({ userName }: { userName: string }) {
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
<Link
href="/note"
href="/"
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">

View file

@ -6,7 +6,7 @@ import { useEffect } from "react";
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
* app/note/annotate/[id]/page.tsx */
* app/annotate/[id]/page.tsx */
export default function DebugConsole() {
useEffect(() => {
if (!new URLSearchParams(window.location.search).has("debug")) return;

View file

@ -47,7 +47,7 @@ export default function EcourtLinkForm() {
}
setError(null);
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
router.push(`/note/annotate?${queryString}`);
router.push(`/annotate?${queryString}`);
};
return (

View file

@ -1,6 +1,6 @@
"use client";
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
@ -36,7 +36,7 @@ export default function LoginForm({ next }: { next: string | null }) {
try {
const result = await login(usernameInput.value, passwordInput.value);
if (result.ok) {
router.replace(next ?? "/note");
router.replace(next ?? "/");
} else {
setError(result.error);
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที

View file

@ -1,7 +1,7 @@
"use client";
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
import { useState, useTransition } from "react";

View file

@ -103,7 +103,7 @@ function RowNote({ text }: { text: string }) {
function OpenFileButton({ file }: { file: GroupFileSummary }) {
return (
<Link
href={`/note/annotate/${file.id}`}
href={`/annotate/${file.id}`}
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
{file.isImage ? (
@ -178,8 +178,8 @@ export function GroupList({
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
: `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว

View file

@ -1664,7 +1664,7 @@ export default function PDFViewer({
: userFilter;
const res = await fetch(
withBasePath(
`/api/note/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
),
);
if (!res.ok) {
@ -1722,10 +1722,10 @@ export default function PDFViewer({
closeToolMenus();
const objectUrls: string[] = [];
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/note/pdf ของเราแทนทางตรง — กัน CORS
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
const fileUrl = isHttpUrl(file.pdfUrl)
? withBasePath(`/api/note/pdf?fileId=${file.id}`)
? withBasePath(`/api/pdf?fileId=${file.id}`)
: withBasePath(file.pdfUrl);
(async () => {
let pages: { src: string | null; width: number; height: number }[];
@ -1767,11 +1767,11 @@ export default function PDFViewer({
(async () => {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
// โหลดผ่าน /api/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
let data: ArrayBuffer;
try {
const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`));
const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.arrayBuffer();
} catch {
@ -3397,7 +3397,7 @@ export default function PDFViewer({
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น
(จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */}
<Link
href={`/note/annotate/${file.id}/summary`}
href={`/annotate/${file.id}/summary`}
title="หน้าสรุปหมายเหตุ"
aria-label="หน้าสรุปหมายเหตุ"
className={`flex items-center gap-1 whitespace-nowrap rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}

View file

@ -165,7 +165,7 @@ function PageSections({
{pageSummaryLine(pageItems)}
</p>
<Link
href={`/note/annotate/${fileId}?page=${pi + 1}`}
href={`/annotate/${fileId}?page=${pi + 1}`}
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
>
เปิดหน้านี้
@ -183,8 +183,8 @@ function PageSections({
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
: `/note/annotate/${fileId}?ann=${item.dto.id}`;
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
: `/annotate/${fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว

View file

@ -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<HTMLDivElement>(null);
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
useEffect(() => {
if (!open) {
setMessage("");
}
}, [open]);
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) onDismiss();
if (event.key === "Tab") {
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
);
const first = focusables?.item(0);
const last = focusables?.item((focusables?.length ?? 0) - 1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onDismiss]);
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previous?.focus();
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
onClick={() => !busy && onDismiss()}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title ?? DEFAULT_TITLE}
tabIndex={-1}
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
onClick={(event) => event.stopPropagation()}
>
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
คำอธิบาย version (ไม่บังคับ)
</label>
<textarea
id="wordaddin-version-message"
className="genesis-input mt-1 block w-full resize-y"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
maxLength={2048}
rows={3}
disabled={busy}
aria-label="คำอธิบาย version"
/>
{busy ? (
<div className="mt-3">
<Spinner label="กำลังดำเนินการ…" />
</div>
) : null}
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
<div className="mt-4 flex flex-col gap-2">
<div className="flex justify-end gap-2">
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
ยกเลิก
</button>
<button
type="button"
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
disabled={busy}
onClick={() => onConfirm(message)}
>
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
</button>
</div>
{onConfirmClose ? (
<button
type="button"
className={`${BTN_PRIMARY} w-full`}
disabled={busy}
onClick={() => onConfirmClose(message)}
>
บันทึกเวอร์ชันและปิดเอกสาร
</button>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default ConfirmVersionDialog;

View file

@ -1,45 +0,0 @@
import type { ReactNode } from "react";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
/**
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
* แถบกว้างเท่ากันทั้งคอลัมน์
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
*/
const ICONS = {
error: CircleAlert,
warning: TriangleAlert,
success: CircleCheck,
} as const;
export function MessageBar({
intent,
children,
action,
}: {
intent: "error" | "warning" | "success";
children: ReactNode;
action?: ReactNode;
}) {
const Icon = ICONS[intent];
const tone =
intent === "error"
? "border-error/30 bg-error/10 text-error-ink"
: intent === "warning"
? "border-warning/30 bg-warning/15 text-warning-ink"
: "border-success/30 bg-success/10 text-success-ink";
return (
<div
role={intent === "error" ? "alert" : "status"}
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 break-words">{children}</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View file

@ -1,65 +0,0 @@
import { CircleCheck, Save, X } from "lucide-react";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
*/
export function SaveControls({
disabled,
busy,
status,
error,
closeSupported,
onSave,
onSaveAndClose,
}: {
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
disabled: boolean;
busy: boolean;
status: "saving" | "saved" | null;
error: string | null;
closeSupported: boolean;
onSave: () => void;
onSaveAndClose: () => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSave}
>
<Save className="size-4" aria-hidden />
บันทึก
</button>
{closeSupported ? (
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSaveAndClose}
>
<X className="size-4" aria-hidden />
บันทึกและปิดเอกสาร
</button>
) : null}
</div>
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
{!busy && status === "saved" ? (
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
<CircleCheck className="size-4 text-success-ink" aria-hidden />
บันทึกแล้ว
</p>
) : null}
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
</div>
);
}
export default SaveControls;

View file

@ -1,13 +0,0 @@
import { LoaderCircle } from "lucide-react";
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
return (
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
<LoaderCircle className="size-4 animate-spin" aria-hidden />
{label ? <span>{label}</span> : null}
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
</span>
);
}

View file

@ -1,646 +0,0 @@
/* global Office */
"use client";
import { useCallback, useEffect, useState } from "react";
import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react";
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
import * as authService from "@/wordaddin/services/authService";
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
import * as documentService from "@/wordaddin/services/documentService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import * as metadataService from "@/wordaddin/services/metadataService";
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog";
import { MessageBar } from "./MessageBar";
import SaveControls from "./SaveControls";
import { Spinner } from "./Spinner";
import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel";
/**
* Task pane หลัก — state machine ตาม Phase 5:
* checking → (ไม่มี token) signed-out
* → (มี token + ไม่ใช่ tracked document) untracked
* → (มี token + tracked document) tracking
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว
* เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles,
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
*
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
*
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
*/
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
type Tab = "versioning" | "template";
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
const tabButtonClass = (active: boolean) =>
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
}`;
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
function demoVersions(now: number): VersionRecord[] {
return [
{
versionId: "demo-v3",
versionNumber: 3,
createdAt: new Date(now - 3_600_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "ปรับคำร้องตามคำแนะนำอัยการ",
hasPdf: true,
fields: null,
},
{
versionId: "demo-v2",
versionNumber: 2,
createdAt: new Date(now - 86_400_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: null,
hasPdf: true,
fields: null,
},
{
versionId: "demo-v1",
versionNumber: 1,
createdAt: new Date(now - 172_800_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "สร้างเอกสารครั้งแรก",
hasPdf: false,
fields: null,
},
];
}
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
const [activeTab, setActiveTab] = useState<Tab>("versioning");
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
const [templateBusy, setTemplateBusy] = useState(false);
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
const [bootError, setBootError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [signingIn, setSigningIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [saving, setSaving] = useState(false);
const [progressMessage, setProgressMessage] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
const [flowError, setFlowError] = useState<string | null>(null);
const [flowWarning, setFlowWarning] = useState<string | null>(null);
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
const [closeSupported] = useState(() => documentService.canCloseDocument());
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
const [saveBusy, setSaveBusy] = useState(false);
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
const [hasUncommitted, setHasUncommitted] = useState(false);
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("signed-out");
setActiveTab("versioning");
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
return true;
}
return false;
}, []);
const refreshHistory = useCallback(
async (id: string) => {
setHistoryLoading(true);
setHistoryError(null);
try {
setVersions(await getVersions(id));
} catch (error) {
if (!handleAuthRequired(error)) {
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
}
} finally {
setHistoryLoading(false);
}
},
[handleAuthRequired],
);
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
* e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */
const detect = useCallback(async () => {
setPhase("checking");
setBootError(null);
try {
let properties: metadataService.TrackedDocumentProperties | null = null;
if (!(await authService.getAccessToken())) {
// e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login
// (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม)
try {
properties = await metadataService.readTrackedProperties();
} catch {
properties = null;
}
if (
!properties ||
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
) {
setPhase("signed-out");
return;
}
}
authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null);
properties ??= await metadataService.readTrackedProperties();
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined;
if (!id) {
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
setDocumentTemplateId(properties.templateId ?? null);
setPhase("untracked");
return;
}
setDocumentId(id);
setDocumentTemplateId(null);
setPhase("tracking");
await refreshHistory(id);
} catch (error) {
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
}
}, [refreshHistory]);
useEffect(() => {
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
void detect();
}, [demo, detect]);
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
useEffect(() => {
autoSaveService.onError = (error) =>
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
autoSaveService.onSaved = () => setAutoSaveError(null);
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
if (!demo) {
autoSaveService.start();
}
return () => {
autoSaveService.stop();
autoSaveService.onError = undefined;
autoSaveService.onSaved = undefined;
autoSaveService.onStateChange = undefined;
};
}, [demo]);
const handleSignIn = async () => {
setSigningIn(true);
setLoginError(null);
try {
const result = await authService.login();
if (result.ok) {
await detect();
} else {
setLoginError(result.error);
}
} catch (error) {
setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ"));
} finally {
setSigningIn(false);
}
};
const handleSignOut = () => {
authService.signOut();
setUsername(null);
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError(null);
setPhase("signed-out");
setActiveTab("versioning");
};
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
const handleSaveOnly = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.save();
setSaveStatus("saved");
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
const handleSaveAndClose = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.suspend();
try {
await autoSaveService.forceSave();
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
} finally {
autoSaveService.resume();
}
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
const handleStartVersioning = async (message: string) => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
setDocumentId(result.documentId);
setDocumentTemplateId(null);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(result.documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setStarting(false);
}
};
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
* message คือ commit message จาก dialog (FR-13)
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
if (!documentId) {
return;
}
setSaving(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
if (closeAfter) {
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
return;
}
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(documentId);
} catch (error) {
if (error instanceof VersionSavedButCloseFailedError) {
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
setFlowWarning(error.message);
await refreshHistory(documentId);
return;
}
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setSaving(false);
}
};
/**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
*/
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
if (!documentId) {
return;
}
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
setDownloading(version.versionId);
setFlowError(null);
try {
const link = await getDownloadLink(documentId, version.versionId, format);
if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(
format === "pdf"
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
);
} else {
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
}
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
}
} finally {
setDownloading(null);
}
};
return (
<div className="flex min-h-screen flex-col gap-2 p-4">
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
{phase !== "signed-out" && phase !== "checking" && username ? (
/* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */
<button
type="button"
className="focus-ring inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:text-primary"
onClick={handleSignOut}
aria-label={`ออกจากระบบ (${username})`}
title={`${username} — ออกจากระบบ`}
>
<LogOut className="size-3.5" aria-hidden />
ออกจากระบบ
</button>
) : null}
</div>
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
<MessageBar intent="warning">{autoSaveError}</MessageBar>
) : null}
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ)
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
{(phase === "untracked" || phase === "tracking") && (
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
<button
type="button"
aria-pressed={activeTab === "versioning"}
className={tabButtonClass(activeTab === "versioning")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("versioning")}
>
Versioning
</button>
<button
type="button"
aria-pressed={activeTab === "template"}
className={tabButtonClass(activeTab === "template")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("template")}
>
เครื่องมือ Template
</button>
</div>
)}
{phase === "checking" ? (
<div className="flex flex-col items-center gap-4 pt-10">
<Spinner label="กำลังตรวจสอบเอกสาร…" />
{bootError ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => void detect()}
>
ลองใหม่
</button>
}
>
{bootError}
</MessageBar>
) : null}
</div>
) : null}
{phase === "signed-out" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</p>
<p className="text-sm text-ink-soft">
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
</p>
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={signingIn}
onClick={() => void handleSignIn()}
>
{signingIn ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
</button>
{loginError ? <MessageBar intent="warning">{loginError}</MessageBar> : null}
</div>
) : null}
{phase === "untracked" && activeTab === "versioning" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
<p className="text-sm text-ink-soft">
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</p>
{documentTemplateId ? (
<p className="text-sm text-ink-soft">
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
และ sync ค่า fields ให้อัตโนมัติ
</p>
) : null}
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={starting}
onClick={() => setStartConfirmOpen(true)}
>
<FileUp className="size-4" aria-hidden />
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
</button>
<SaveControls
disabled={starting}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
</div>
) : null}
{phase === "tracking" && activeTab === "versioning" ? (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
<div className="flex flex-col gap-2 pt-4">
{/* กลุ่มข้อความสถานะ */}
{hasUncommitted ? (
<MessageBar intent="warning">
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save &amp; Version เพื่อบันทึกเป็น version ใหม่
</MessageBar>
) : null}
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
<div className="flex flex-col gap-2 pt-2">
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
disabled={saving}
onClick={() => setConfirmOpen(true)}
>
<GitCommitHorizontal className="size-4" aria-hidden />
Save &amp; Version
</button>
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
<SaveControls
disabled={saving}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
</div>
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
<VersionHistoryPanel
versions={versions}
loading={historyLoading}
error={historyError}
downloadingVersionId={downloadingVersionId}
downloadingPdfVersionId={downloadingPdfVersionId}
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)}
/>
</div>
) : null}
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
) : null}
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
เปิดค้างขณะสลับแท็บ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
<>
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
<ConfirmVersionDialog
open={confirmOpen}
busy={saving}
onConfirm={(message) => {
setConfirmOpen(false);
void handleSaveAndVersion(message);
}}
{...(closeSupported
? {
onConfirmClose: (message: string) => {
setConfirmOpen(false);
void handleSaveAndVersion(message, true);
},
}
: {})}
onDismiss={() => setConfirmOpen(false)}
/>
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
<ConfirmVersionDialog
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => {
setStartConfirmOpen(false);
void handleStartVersioning(message);
}}
onDismiss={() => setStartConfirmOpen(false)}
/>
</>
) : null}
</div>
);
};
export default TaskpaneApp;

View file

@ -1,411 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react";
import {
applyTestValues,
insertBreakAtSelection,
insertParagraphAtSelection,
listContentControls,
selectContentControl,
supportsAllContentControlTypes,
type ScannedField,
} from "@/wordaddin/services/templateService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import { MessageBar } from "./MessageBar";
/**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template
* สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking:
* 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง
* 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที
* โดยไม่ต้อง render ผ่าน /api/template/render)
* 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง
*
* state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง
* ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น)
*/
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
const BTN_SECONDARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */
const BTN_INSERT =
"focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) {
/** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */
const [fields, setFields] = useState<ScannedField[] | null>(null);
const [scanning, setScanning] = useState(false);
const [applying, setApplying] = useState(false);
const [insertingParagraph, setInsertingParagraph] = useState(false);
const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null);
/** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(null);
const busy = scanning || applying || insertingParagraph || insertingBreak !== null;
/** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ
* Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount
* แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */
useEffect(() => {
onBusyChange?.(busy);
return () => onBusyChange?.(false);
}, [busy, onBusyChange]);
/** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */
const uniqueTags = useMemo(() => {
if (!fields) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}, [fields]);
const emptyTagCount = useMemo(
() => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0),
[fields],
);
/** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */
const duplicateTags = useMemo(() => {
if (!fields) return [];
const counts = new Map<string, number>();
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "") continue;
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([tag]) => tag);
}, [fields]);
/** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย
* แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */
const hasFilledValue = useMemo(
() => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""),
[uniqueTags, values],
);
/**
* สแกนเอกสาร + เติมค่าฟอร์ม
* - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag
* พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว)
* - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม
* และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ
* **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่
* (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน)
*/
const runScan = async (merge: boolean) => {
setScanning(true);
setError(null);
if (!merge) {
setSuccess(null);
setWarning(null);
}
try {
const scanned = await listContentControls();
setFields(scanned);
// Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล
// ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน
setCapabilityWarning(
supportsAllContentControlTypes()
? null
: "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้",
);
setValues((previous) => {
// สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่
// (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป)
const next: Record<string, string> = {};
const seen = new Set<string>();
for (const field of scanned) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
next[tag] = merge && tag in previous ? previous[tag] : field.text;
}
return next;
});
} catch (scanError) {
setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ"));
} finally {
setScanning(false);
}
};
const handleView = async (id: number) => {
setError(null);
try {
await selectContentControl(id);
} catch (viewError) {
setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ"));
}
};
/** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */
const handleInsertParagraph = async () => {
setInsertingParagraph(true);
setError(null);
try {
await insertParagraphAtSelection();
} catch (insertError) {
setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ"));
} finally {
setInsertingParagraph(false);
}
};
const handleInsertBreak = async (breakKind: "line" | "page") => {
setInsertingBreak(breakKind);
setError(null);
try {
await insertBreakAtSelection(breakKind);
} catch (insertError) {
setError(
getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"),
);
} finally {
setInsertingBreak(null);
}
};
const handleApply = async () => {
setApplying(true);
setError(null);
setSuccess(null);
setWarning(null);
try {
const result = await applyTestValues(values);
if (result.appliedCount === 0 && result.failures.length === 0) {
setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่");
} else {
if (result.appliedCount > 0) {
setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`);
}
const problems: string[] = [];
if (result.missingTags.length > 0) {
problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`);
}
if (result.failures.length > 0) {
const first = result.failures[0];
problems.push(
`เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures
.map((failure) => failure.tag)
.join(", ")}) — ${first.reason}`,
);
}
if (problems.length > 0) {
setWarning(
`${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`,
);
}
}
// สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ
await runScan(true);
} catch (applyError) {
setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ"));
} finally {
setApplying(false);
}
};
return (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
<div className="flex flex-col gap-2 pt-4">
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
{/* §1 ตรวจสอบ Content Controls */}
<div className="mt-2 flex items-baseline justify-between gap-2">
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
</div>
<button
type="button"
className={`${BTN_SECONDARY} w-full justify-center`}
disabled={busy}
aria-busy={scanning}
onClick={() => void runScan(false)}
>
{scanning ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<ScanSearch className="size-4" aria-hidden />
)}
สแกนช่องในเอกสาร
</button>
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
<MessageBar intent="warning">
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
{duplicateTags.length > 0
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
: ""}
</MessageBar>
) : null}
{!fields && !scanning ? (
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
) : null}
{fields && fields.length === 0 && !scanning ? (
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
) : null}
{fields && fields.length > 0 ? (
<ol className="relative">
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
{fields.map((field, index) => (
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-ink-faint">{index + 1}.</span>
<span
className="break-all text-sm font-medium text-ink"
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
>
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
</span>
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
</span>
<span className="ml-auto">
<button
type="button"
className={BTN_ACTION}
disabled={busy}
onClick={() => void handleView(field.id)}
>
<Crosshair className="size-3.5" aria-hidden />
ดูช่อง
</button>
</span>
</div>
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
{field.text.trim() === "" ? "(ว่าง)" : field.text}
</p>
</li>
))}
</ol>
) : null}
{/* §2 กรอกค่าทดสอบ */}
{uniqueTags.length > 0 ? (
<>
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
<p className="text-sm text-ink-soft">
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
</p>
<div className="flex flex-col gap-2" aria-busy={applying}>
{uniqueTags.map((tag, index) => (
<div key={tag} className="flex flex-col gap-1">
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
{tag}
</label>
<input
id={`wordaddin-template-value-${index}`}
className="genesis-input block w-full"
value={values[tag] ?? ""}
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
disabled={busy}
/>
</div>
))}
</div>
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center`}
disabled={busy || !hasFilledValue}
onClick={() => void handleApply()}
>
<PenLine className="size-4" aria-hidden />
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
</button>
</>
) : fields ? (
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
) : (
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
)}
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
<div className="grid grid-cols-3 gap-1">
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingParagraph}
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
onClick={() => void handleInsertParagraph()}
>
{insertingParagraph ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<CornerDownLeft className="size-4" aria-hidden />
)}
ย่อหน้า
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "line"}
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
onClick={() => void handleInsertBreak("line")}
>
{insertingBreak === "line" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<WrapText className="size-4" aria-hidden />
)}
แบ่งบรรทัด
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "page"}
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
onClick={() => void handleInsertBreak("page")}
>
{insertingBreak === "page" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<FilePlus className="size-4" aria-hidden />
)}
แบ่งหน้า
</button>
</div>
</div>
);
}
export default TemplateToolsPanel;

View file

@ -1,149 +0,0 @@
import { Download, FileText, LoaderCircle } from "lucide-react";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
*/
function formatTimestamp(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
export function VersionHistoryPanel({
versions,
loading,
error,
downloadingVersionId,
downloadingPdfVersionId,
onDownloadPdf,
onDownload,
onRetry,
}: {
versions: VersionRecord[];
loading: boolean;
error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null;
downloadingPdfVersionId: string | null;
onDownloadPdf: (version: VersionRecord) => void;
onDownload: (version: VersionRecord) => void;
onRetry: () => void;
}) {
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
return (
<div className="flex flex-col gap-1.5">
<div className="mt-4 mb-2 flex items-baseline justify-between">
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
</div>
{loading ? <Spinner label="กำลังโหลด…" /> : null}
{error ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={onRetry}
>
ลองใหม่
</button>
}
>
{error}
</MessageBar>
) : null}
{!loading && !error && versions.length === 0 ? (
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
) : null}
{versions.length > 0 ? (
<ol className="relative">
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
{versions.length > 1 ? (
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
) : null}
{versions.map((version, index) => {
const isLatest = index === 0;
return (
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
{isLatest ? (
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
) : null}
<span className="ml-auto flex items-center gap-1">
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingVersionId === version.versionId}
onClick={() => onDownload(version)}
>
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
{downloadingVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<Download className="size-3.5" aria-hidden />
)}
ดาวน์โหลด
</button>
{version.hasPdf ? (
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingPdfVersionId === version.versionId}
onClick={() => onDownloadPdf(version)}
>
{downloadingPdfVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<FileText className="size-3.5" aria-hidden />
)}
PDF
</button>
) : null}
</span>
</div>
{version.message ? (
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
{version.message}
</p>
) : null}
<p className="mt-0.5 text-xs text-ink-soft">
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
</p>
</li>
);
})}
</ol>
) : null}
</div>
);
}
export default VersionHistoryPanel;

View file

@ -1,131 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { withBasePath } from "@/utils/base-path";
import { signInForDemo } from "@/wordaddin/services/authService";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
import TaskpaneApp from "./TaskpaneApp";
/**
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
* Office ที่ module top-level จึงปลอดภัย)
*
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
*/
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
const ONREADY_FALLBACK_MS = 5_000;
/**
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
*/
let officeReadyPromise: Promise<boolean> | null = null;
function ensureOfficeJs(): Promise<boolean> {
if (!officeReadyPromise) {
officeReadyPromise = new Promise<boolean>((resolve) => {
if (typeof Office !== "undefined") {
void Office.onReady(() => resolve(true));
return;
}
const script = document.createElement("script");
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
const onReady = () => {
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
void Office.onReady(() => {
clearTimeout(fallbackTimer);
resolve(true);
});
};
script.onload = onReady;
script.onerror = () => {
clearTimeout(fallbackTimer);
officeReadyPromise = null;
script.remove();
resolve(false);
};
script.src = OFFICE_JS_SRC;
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
script.id = "office-js";
document.head.appendChild(script);
});
}
return officeReadyPromise;
}
type GateStatus = "loading" | "ready" | "error";
export function WordAddinGate() {
const [status, setStatus] = useState<GateStatus>("loading");
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
useEffect(() => {
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
signInForDemo();
setDemo(demoParam);
setStatus("ready");
return;
}
let cancelled = false;
void ensureOfficeJs()
.then((ok) => {
if (!cancelled) setStatus(ok ? "ready" : "error");
})
.catch(() => {
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, []);
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
</div>
);
}
if (status === "error") {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => window.location.reload()}
>
ลองใหม่
</button>
}
>
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
</MessageBar>
</div>
);
}
return <TaskpaneApp demo={demo ?? undefined} />;
}
export default WordAddinGate;

View file

@ -253,76 +253,12 @@ export const annotationVersions = pgTable(
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
);
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
docId: text("doc_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
);
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
export const documentVersions = pgTable(
"document_versions",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id")
.notNull()
.references(() => documents.id, { onDelete: "cascade" }),
versionNumber: integer("version_number").notNull(),
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
clientVersionId: text("client_version_id"),
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
contentHash: text("content_hash").notNull(),
/** commit message (FR-13) — trim แล้วว่าง = NULL */
message: text("message"),
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
fields: jsonb("fields").$type<Record<string, string>>(),
templateId: text("template_id"),
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
fileName: text("file_name"),
docxObjectKey: text("docx_object_key").notNull(),
pdfObjectKey: text("pdf_object_key"),
docxSize: integer("docx_size").notNull(),
pdfSize: integer("pdf_size"),
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
],
);
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
export type AnnotationRow = typeof annotations.$inferSelect;
export type BookmarkRow = typeof bookmarks.$inferSelect;
export type ReplyRow = typeof annotationReplies.$inferSelect;
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
export type DocumentRow = typeof documents.$inferSelect;
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
export type AnnotationDTO = {
id: string;
@ -435,33 +371,3 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
createdAt: row.createdAt.toISOString(),
};
}
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
export type DocumentVersionDTO = {
versionId: string;
versionNumber: number;
createdAt: string;
createdByUserId: string;
createdByUserName: string;
contentHash: string;
fields: Record<string, string> | null;
message: string | null;
hasPdf: boolean;
};
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
return {
versionId: row.id,
versionNumber: row.versionNumber,
createdAt: row.createdAt.toISOString(),
createdByUserId: row.userId,
createdByUserName: row.userName,
contentHash: row.contentHash,
fields: row.fields,
message: row.message,
hasPdf: row.pdfObjectKey !== null,
};
}

View file

@ -1,58 +0,0 @@
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
#
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
services:
db:
image: postgres:17-alpine
container_name: judes-note-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
POSTGRES_DB: ecourt-note
ports:
- "54322:5432"
volumes:
- judes-note-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 3s
retries: 10
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
minio:
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: judes-note-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
- "19001:9001" # console ดู bucket/object ผ่าน browser
volumes:
- judes-note-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
volumes:
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
judes-note-db-data:
name: judes-note-db-data
judes-note-minio-data:
name: judes-note-minio-data

View file

@ -2,11 +2,10 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
RUN npm ci
# postinstall รัน scripts/patch-pdf-worker.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
COPY scripts/patch-pdf-worker.mjs scripts/
# postinstall เขียน public/pdf.worker.min.mjs — ต้องมีโฟลเดอร์ public/ ก่อน ไม่งั้น npm ci ล้ม
RUN mkdir -p public && npm ci
# ─── Stage 2: build Next.js (output: standalone) ───
FROM node:24-alpine AS builder
@ -16,27 +15,13 @@ COPY . .
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
# + ดาวน์โหลด Office.js runtime ล่าสุด (public/wordaddin/office-js/) ของ taskpane wordaddin
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs
RUN node scripts/patch-pdf-worker.mjs
ENV NEXT_TELEMETRY_DISABLED=1
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
ENV MINIO_ENDPOINT=127.0.0.1
ENV MINIO_PORT=19000
ENV MINIO_USE_SSL=false
ENV MINIO_ACCESS_KEY=minioadmin
ENV MINIO_SECRET_KEY=minioadmin
ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54
ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e
ENV DOCVER_TOKEN_TTL_SECONDS=604800
ENV MINIO_BUCKET=docver
ENV DOCVER_LINK_TTL_SECONDS=900
RUN npm run build
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
FROM node:24-alpine AS runner
WORKDIR /app
@ -45,14 +30,10 @@ ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
# Next 14.2 standalone **ไม่รวม public/ และ .next/static เอง** — ต้องคัดลอกเองทั้งคู่
# (ก่อนหน้านี้เข้าใจผิดว่า Next รวม public/ ให้: จริง ๆ มันหลุดเข้ามาแค่บางส่วนผ่าน output
# file tracing ที่ตาม fs.readFile ของ server code (public/note จาก lib/server-pdf.ts,
# ฟอนต์ Sarabun จาก export route) — public/wordaddin/office-js ไม่มีใครอ่านผ่าน fs
# จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
# Next 14.2 รวม public/ (pdf.worker, ฟอนต์ Sarabun, samples PDF) ไว้ใน standalone แล้ว
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
@ -62,10 +43,10 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
RUN mkdir -p public/note/uploads && chown nextjs:nodejs public/note/uploads
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
USER nextjs
EXPOSE 3000
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
# seed-samples sync ตาราง files กับ public/note/samples/ ที่ bake อยู่ใน image (idempotent)
# seed-samples sync ตาราง files กับ public/samples/ ที่ bake อยู่ใน image (idempotent)
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]

View file

@ -19,7 +19,7 @@
| สิ่งที่ต้องมี | ค่า |
|---|---|
| URL | `https://<host>/docedit/api/note/external/documents` (API อยู่ใต้ `app/api/note/**` ของ basePath `/docedit` — ต่างจากหน้า UI ที่อยู่ใต้ `/docedit/note/…`) |
| URL | `https://<host>/docedit/note/api/external/documents` (แอป serve ใต้ basePath `/docedit/note`) |
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
| Content-Type | `application/json` |
@ -29,7 +29,7 @@
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
```http
POST /docedit/api/note/external/documents HTTP/1.1
POST /docedit/note/api/external/documents HTTP/1.1
Host: <host>
x-api-key: <API Key ที่ได้รับ>
content-type: application/json
@ -191,7 +191,7 @@ error ทุกสถานะเป็น JSON รูปเดียวกั
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234"}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
```
Response (ตัดย่อ):
@ -256,7 +256,7 @@ Response (ตัดย่อ):
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
```
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
@ -266,7 +266,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
```
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
@ -276,7 +276,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/api/note/external/documents"
"http://localhost:3000/docedit/note/api/external/documents"
```
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404

View file

@ -1,222 +0,0 @@
# PLAN-DOCVER — Document Versioning API (stand-alone)
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
## 0. บริบท/เป้าหมาย
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
`wordaddin/services/versioningService.ts`):
- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid`
(string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก
endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
response)
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22
— เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
พาหะ identity เดียว
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
ของ N10) · ไม่มี CORS
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
(client-only zone) type define เองใน `db/schema.ts`/`lib`
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
middleware เหมือน `api/template`)
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
JSON body (allowlist — key อื่น = 400 กัน typo):
| field | type | หมายเหตุ |
|---|---|---|
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid |
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
### `POST /api/documents/versions` — อัปโหลด version ใหม่
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ
`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป
multipart/form-data:
| field | type | หมายเหตุ |
|---|---|---|
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) |
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) —
**breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว
(ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
"สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
(= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server**
(client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token)
response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in +
ส่วนเสริม `createdByUserName`):
```ts
{
versionId: string; // = id ของ row (uuid)
versionNumber: number;
createdAt: string; // ISO-8601
createdByUserId: string; // = user_id
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
fields: Record<string, string> | null;
message: string | null;
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
}
```
- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ
N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf`
- format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400
- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id
ข้ามเอกสาร) — ไม่เจอ = 404
- `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
- response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET**
(อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ
ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
### ตาราง error
| เหตุ | สถานะ |
|---|---|
| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 |
| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 |
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
## 2. ตาราง (db/schema.ts)
```
documents — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
document_versions — id uuid PK · document_id uuid FK→documents CASCADE
· version_number int NOT NULL (+ UNIQUE(document_id, version_number))
· client_version_id · content_hash NOT NULL · message · fields jsonb
· template_id · file_name · docx_object_key NOT NULL · pdf_object_key
· docx_size int NOT NULL · pdf_size int · user_id NOT NULL
· user_name NOT NULL · created_at
+ index (document_id, created_at)
```
- **`documents` ≠ `files`** ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน
(ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
- `content_hash`/`docx_size` เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน
insert เสมอ · `hasPdf` **ไม่มี column** — derive จาก `pdf_object_key !== null`
- object key ใน MinIO: `docver/<versionId>/document.docx` / `document.pdf` — **uuid ล้วน**
ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · `versionId` สร้าง
ด้วย `randomUUID()` ฝั่ง server แล้วส่งเป็น `id` ตอน insert (object เขียนไปก่อนแล้ว)
## 3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน
เลือก: **insert-on-conflict + `SELECT … FOR UPDATE` ใน transaction เดียว** (ใช้ `.for("update")`
ที่ repo ใช้อยู่แล้วใน `app/actions/annotations.ts`):
1. `INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING` — สร้างเอกสารถ้ายังไม่มี
2. `SELECT id FROM documents WHERE doc_id = … FOR UPDATE` — serialize ผู้จ่ายเลขรายเอกสาร
(unique `doc_id` → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
3. `SELECT max(version_number) + 1` แล้ว insert row version
ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) ·
`pg_advisory_xact_lock` (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE
`(document_id, version_number)` เก็บไว้เป็น safety net
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า
validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId`
→ **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
- crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) —
**ไม่ทำ GC รอบนี้** บันทึกไว้เป็นงานอนาคต
- ไม่มี idempotency บน `clientVersionId` — retry หลัง network timeout เกิด version ใหม่
(client แก้เลขจาก response เองได้) ยอมรับได้
## 5. Carve-out จาก invariant #4 (CLAUDE.md)
กลุ่ม API นี้ **เขียน DB ผ่าน route handler** (ไม่ใช่ Server Action) และไม่ publish SSE
ไม่ `revalidatePath` — ตาราง `documents`/`document_versions` เป็นของกลุ่มนี้ล้วน ไม่มีหน้า
note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ
read-only ของ N10 แต่ตัวนี้เขียนได้)
## 6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)
| env | default | หมายเหตุ |
|---|---|---|
| `MINIO_ENDPOINT` | — บังคับ | host อย่างเดียว ไม่มี scheme |
| `MINIO_PORT` | `9000` | dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง) |
| `MINIO_USE_SSL` | `false` | |
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ |
| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
ตอนดาวน์โหลด
## 7. ไฟล์ของกลุ่มนี้
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer`
+ `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`)
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key)
- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token)
- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure
## 8. Out of scope รอบนี้
- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)

View file

@ -1,75 +0,0 @@
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
## ไฟล์หลัก
| ที่ | คือ |
|---|---|
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler |
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock |
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
## Env
| ตัวแปร | default | ใช้เมื่อไหร่ |
|---|---|---|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) |
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` |
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
manifest ต้องชี้ https — 2 ทางเลือก:
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
## Sideload ใน Word Desktop (Windows)
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word
2. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
3. ชื่อไฟล์ไม่สำคัญ — Word สแกน `*.xml` ทุกไฟล์ในโฟลเดอร์ โดย `manifest.template.xml` คือ dev manifest อยู่แล้ว (URL localhost:3000) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout อยู่เสมอ — **ห้ามวาง xml manifest อื่นเพิ่มในโฟลเดอร์นี้** (GUID เดียวกันสองไฟล์ = Word สับสน)
เครื่องที่ไม่มี repo (production/เครื่องผู้ใช้):
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
2. เอาไฟล์ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
3. ทำขั้น Trust Center ข้างบนด้วยโฟลเดอร์นั้น > restart Word > **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ)
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
## ทดสอบ e-Court pass-through (ข้าม login)
ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะเข้าใช้ได้เลยโดยไม่ต้องกด Sign in — `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ)
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login
## การสลับกลับของจริง (วันหน้า)
- **Auth:** `wordaddin/services/authService.ts` เป็น mock คง surface เดิมครบ (`login/getAccessToken/refreshAccessToken/getUsername/signOut/startAutoRefresh/...`) — เขียน implementation จริงทับ (ต้นฉบับ Keycloak PKCE + Office Dialog API อยู่ที่ `D:\word-addin-poc\addin\src\services\authService.ts`; ถ้าใช้ ต้องเพิม DialogAPI กลับเข้า manifest + หน้า auth-start/auth-callback) · `signInFromDocument` (pass-through จาก token/owner ในไฟล์) เป็น mock-only — วันหน้า token ในไฟล์ควรเป็นแค่ตัวจับคู่เอกสาร ส่วนสิทธิ์ผู้ใช้พิสูจน์ตอน runtime
- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`
- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/`
## Docker
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ runner stage คัดลอก `public/` เข้า image เอง (`COPY /app/public ./public` — **Next 14.2 standalone ไม่รวม `public/` ให้อัตโนมัติ** ต้อง copy เองตามเอกสาร deploy ของ Next; เคยเข้าใจผิดว่า Next รวมให้ เพราะบางไฟล์หลุดเข้า image ผ่าน output file tracing ของ Next ที่ตาม `fs.readFile` ใน server code — แต่ `public/wordaddin/office-js` ไม่มีใครอ่านผ่าน fs จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile

View file

@ -1,317 +0,0 @@
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
---
## 1. State machine หลัก
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
```
checking ──(ไม่มี token)──────────────→ signed-out
│
├─(มี token + ไม่ใช่ tracked doc)──→ untracked
└─(มี token + tracked doc)────────→ tracking
```
การเปลี่ยน phase อื่นๆ:
- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out`
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ
State ที่ UI ถือนอกเหนือจาก phase:
| State | ความหมาย |
|---|---|
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) |
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
| `signingIn`, `loginError` | สถานะหน้า sign-in |
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
---
## 2. UI แต่ละ phase
### Header (ทุก phase)
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out`
### `checking`
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
### `signed-out`
- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning"
- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก"
- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable
- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow
> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน
### `untracked`
เรียงจากบนลงล่าง:
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
7. `flowError` → MessageBar **error**
### `tracking`
เรียงจากบนลงล่าง:
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
4. `flowError` → MessageBar **error**
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
---
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
- สถานะตามลำดับ:
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
- `saveError` → MessageBar **error**
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
พฤติกรรมปุ่ม:
| ปุ่ม | Flow |
|---|---|
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
---
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
| Prop | Save & Version | Start Versioning |
|---|---|---|
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
ภายใน dialog:
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
---
## 5. `VersionHistoryPanel` (FR-4)
- Header: icon history + "Version history" (semibold)
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
- `Badge v{versionNumber}` (สี brand)
- timestamp รูปแบบ medium date + short time ตาม locale
- `โดย {createdByUserId}`
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
---
## 6. Flow หลักพร้อมข้อความ progress
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่
1. ตรวจ token — ไม่มี → `signed-out`
2. อ่าน custom properties ครั้งเดียวทั้งชุด: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
### Start Versioning (FR-1) — จาก `untracked`
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
1. `กำลังบันทึก metadata ของเอกสาร…`
2. `กำลังอัปเดต version history ในไฟล์…`
3. `กำลังบันทึกไฟล์…`
4. `กำลังอ่านค่า fields จากเอกสาร…`
5. `กำลังอัปโหลด version 1…`
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
7. `กำลังอัปโหลด version…`
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
### Save & Version (FR-2) — จาก `tracking`
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
### Download version (FR-4/FR-7)
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
### Auto-save (FR-17)
- เริ่มตั้งแต่ mount **ไม่รอ sign-in**
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
---
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
multipart/form-data:
| field | type | หมายเหตุ |
|---|---|---|
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
### `GET /api/documents/{documentId}/versions` — history
response: `VersionRecord[]` เรียงใหม่ → เก่า:
```ts
interface VersionRecord {
versionId: string;
versionNumber: number;
createdAt: string; // ISO-8601
createdByUserId: string;
contentHash: string; // SHA256 hex
message: string | null; // commit message (FR-13)
hasPdf?: boolean;
fields: FieldsSnapshot | null;
}
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
```
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
### Auth — OIDC PKCE ผ่าน Office Dialog API
`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test
> **หมายเหตุสถานะปัจจุบัน (2026-10-14):** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — ทั้ง auth (แทนด้วย mock `wordaddin/services/authService.ts`) และ API ทั้งสามข้างบน (แทนด้วย in-memory mock `wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md`
---
## 8. กติกา error ที่ UI ต้องรองรับ
1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
---
## 9. ข้อควรระวังที่กระทบหน้าตา UI
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
---
## 10. แท็บ "เครื่องมือ Template" (2026-10-19)
เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน
### §1 ตรวจสอบ Content Controls
- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง
- สรุปหัวกลุ่ม: "N ช่อง"
- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag"
- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร")
### §2 กรอกค่าทดสอบ
- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-<index>`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ tag นั้น + ปุ่ม primary `เขียนค่าลงเอกสาร` (icon PenLine → "กำลังเขียนค่า…" ตอนรัน)
- **ช่องที่เว้นว่าง = ไม่ถูกแตะ** (กันเผลอล้างค่าจริง) · เขียนทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่องแบบ mail merge) · อ่าน id+tag สดรอบเดียวแล้วเขียน**แยก Word.run ราย tag** — tag ที่ Word ปฏิเสธ fail เฉพาะตัว เหลืออื่นยังเขียนได้ (ผลลัพธ์: `appliedCount` = จำนวนช่องที่เขียนจริง + `missingTags` ไม่พบช่อง + `failures` พร้อม code/errorLocation จาก OfficeExtension.Error) · UI รวมเป็น warning พร้อมชี้นำ "ถ้าเอกสารถูกล็อค (Review → Restrict Editing) ให้ปลดก่อน" — เคสจริงที่เจอ: error `0x800A16C6` บนเอกสารที่ `make-protected.mjs` ล็อคไว้ (readOnly + enforcement)
- ผลลัพธ์: success "เขียนค่าทดสอบลงแล้ว N ช่อง" (N = จำนวนช่องที่เขียนจริง — tag ซ้ำหลายช่องนับทุกช่อง) + warning missingTags ถ้ามี → **สแกนใหม่โดยคงค่าฟอร์ม** (เฉพาะ tag ที่ยังอยู่ในเอกสาร — tag ที่ถูกลบไปตัดทิ้ง) ให้แก้ต่อ · ปุ่ม disabled เมื่อยังไม่มีช่องที่กรอกค่าเลย · หลังเขียนสำเร็จ `notifyDocumentChanged()` ตั้ง debounce auto-save 3 วิ ส่วน banner FR-3 ตั้งเมื่อ periodic tick ถัดไปของ autoSaveService เห็น document dirty (พฤติกรรมเดิมของ service — ไม่ใช่ทันที)
### §3 แทรกย่อหน้า / ตัวแบ่ง (2026-10-19)
- 3 ปุ่มแนวตั้ง (icon บน label ล่าง, grid 3 คอลัมน์, `title` อธิบายเต็ม): **ย่อหน้า** (CornerDownLeft — `insertParagraph("", "After")` เหมือนกด Enter) · **แบ่งบรรทัด** (WrapText — `insertBreak("Line", "After")` เหมือน Shift+Enter จัดที่อยู่/รายการในย่อหน้าเดิม) · **แบ่งหน้า** (FilePlus — เหมือน Ctrl+Enter เอกสารหลายหน้า: `insertBreak("Page", "After")` ก่อน ถ้า host ปฏิเสธ E_FAIL 0x80004005 (บางบริบท เช่น cursor ในตาราง — เคสจริงบน T3 2026-10-19) สลับไป `insertOoxml` แบบ w:p สมบูรณ์ครอบ `<w:br w:type="page"/>` — fragment ต้องเป็น block-level เต็มตัว ใช้ run เดี่ยวก็ยัง E_FAIL)
- กดแล้วทำที่ cursor ทันที (ไม่มี success message — เห็นผลในเอกสารเลย) · icon สลับเป็น spinner ตอนรันตัวนั้น · ทุกปุ่ม disable ด้วย `busy` ร่วมกับเครื่องมืออื่น · fallback error: "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ"
### หมายเหตุ: ไม่มีปุ่มสลับ formatting marks (และไม่มีชุดแทรกอักขระพิเศษ)
- **Office.js ไม่มี API สลับแสดง/ซ่อน formatting marks** (¶ ท้ายย่อหน้า/จุดช่องว่าง) — เป็น application setting ของ Word ที่ WordApi ไม่เปิดให้ add-in แตะ ผู้ใช้กด **Ctrl+Shift+8** หรือปุ่ม **¶ บนแท็บ Home** ของ Word เอง (workflow จริงของผู้ใช้: แทรกย่อหน้าเพิ่ม → เปิด marks ตรวจ → ซ่อนกลับ — จึงมีปุ่มแทรกย่อหน้าให้ ส่วน marks ใช้ของ Word)
- เคยมีชุด "แทรกอักขระพิเศษ" (เลขไทย ๐–๙ + สัญลักษณ์) ต่อท้าย panel แล้ว**ถอดออก 2026-10-19** ตามคำขอผู้ใช้ — ที่ต้องการจริงคือ formatting marks ซึ่งทำผ่าน add-in ไม่ได้ (อ่านบันทึกเต็มใน git history รอบนั้น)
- เคยมีเครื่องมือ **"สร้างช่องจากข้อความที่เลือก"** (dialog ตั้ง tag + `insertContentControl` ครอบ selection + `validateTag` + `TagInputDialog` + unit test ของ validateTag) แล้ว**ถอดออกทั้งเส้น 2026-10-19** ตามคำขอผู้ใช้ — การสร้างช่องใหม่ทำใน Word ด้วย Developer tab แทน
### ข้อความ error กลางของ panel (MessageBar intent error)
- สแกนพัง: fallback "สแกนช่องในเอกสารไม่สำเร็จ" · ดูช่องพัง: "ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่" / fallback "เลือกช่องไม่สำเร็จ" · เขียนค่าพัง: fallback "เขียนค่าลงเอกสารไม่สำเร็จ" · แทรกพัง: fallback "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" (แบ่งหน้าที่พังทั้งสองวิธีจะรวมสาเหตุของทั้ง insertBreak และ OOXML fallback ในข้อความเดียว)
### พฤติกรรมใน browser ธรรมดา / `?addindemo`
ทุกปุ่มที่แตะเอกสาร (`สแกน`/`ดูช่อง`/`เขียนค่า`/`ย่อหน้า`/`แบ่งบรรทัด`/`แบ่งหน้า`) จบที่ error MessageBar ("Word is not defined") — ตามปรัชญา demo ของ repo เหมือนปุ่ม Save & Version ส่วน toggle/สลับแท็บ/ฟอร์มค่าทดสอบ ทดสอบได้ครบใน browser

View file

@ -1,29 +0,0 @@
CREATE TABLE "document_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" uuid NOT NULL,
"version_number" integer NOT NULL,
"client_version_id" text,
"content_hash" text NOT NULL,
"message" text,
"fields" jsonb,
"template_id" text,
"file_name" text,
"docx_object_key" text NOT NULL,
"pdf_object_key" text,
"docx_size" integer NOT NULL,
"pdf_size" integer,
"user_id" text NOT NULL,
"user_name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"doc_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");

View file

@ -1,735 +0,0 @@
{
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.annotation_replies": {
"name": "annotation_replies",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"annotation_id": {
"name": "annotation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"annotation_replies_annotation_idx": {
"name": "annotation_replies_annotation_idx",
"columns": [
{
"expression": "annotation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotation_replies_annotation_id_annotations_id_fk": {
"name": "annotation_replies_annotation_id_annotations_id_fk",
"tableFrom": "annotation_replies",
"tableTo": "annotations",
"columnsFrom": [
"annotation_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.annotation_versions": {
"name": "annotation_versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"annotation_id": {
"name": "annotation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "annotation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"annotation_versions_file_idx": {
"name": "annotation_versions_file_idx",
"columns": [
{
"expression": "file_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotation_versions_file_id_files_id_fk": {
"name": "annotation_versions_file_id_files_id_fk",
"tableFrom": "annotation_versions",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.annotations": {
"name": "annotations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "annotation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved": {
"name": "resolved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"annotations_file_page_idx": {
"name": "annotations_file_page_idx",
"columns": [
{
"expression": "file_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "page_index",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotations_file_id_files_id_fk": {
"name": "annotations_file_id_files_id_fk",
"tableFrom": "annotations",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.bookmarks": {
"name": "bookmarks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
},
"y_offset": {
"name": "y_offset",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"bookmarks_file_id_files_id_fk": {
"name": "bookmarks_file_id_files_id_fk",
"tableFrom": "bookmarks",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.document_versions": {
"name": "document_versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"document_id": {
"name": "document_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version_number": {
"name": "version_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"client_version_id": {
"name": "client_version_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fields": {
"name": "fields",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"docx_object_key": {
"name": "docx_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"pdf_object_key": {
"name": "pdf_object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"docx_size": {
"name": "docx_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"pdf_size": {
"name": "pdf_size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"document_versions_document_number_key": {
"name": "document_versions_document_number_key",
"columns": [
{
"expression": "document_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "version_number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"document_versions_document_created_idx": {
"name": "document_versions_document_created_idx",
"columns": [
{
"expression": "document_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"document_versions_document_id_documents_id_fk": {
"name": "document_versions_document_id_documents_id_fk",
"tableFrom": "document_versions",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"doc_id": {
"name": "doc_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"documents_doc_id_key": {
"name": "documents_doc_id_key",
"columns": [
{
"expression": "doc_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.files": {
"name": "files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type_id": {
"name": "type_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"files_type_id_idx": {
"name": "files_type_id_idx",
"columns": [
{
"expression": "type_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"files_external_id_key": {
"name": "files_external_id_key",
"columns": [
{
"expression": "external_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.annotation_type": {
"name": "annotation_type",
"schema": "public",
"values": [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -36,13 +36,6 @@
"when": 1790072946313,
"tag": "0004_right_ted_forrester",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1792467537332,
"tag": "0005_icy_bloodaxe",
"breakpoints": true
}
]
}

View file

@ -5,7 +5,7 @@ import type { AnnotateEvent } from "@/lib/annotate-events";
import { withBasePath } from "@/utils/base-path";
// SSE sync (PLAN.md §4, rules/realtime.md)
// - เปิด EventSource ไป /api/note/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
// - เปิด EventSource ไป /api/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
@ -20,7 +20,7 @@ export function useAnnotationRealtime(
const [replies, setReplies] = useState(initialReplies);
useEffect(() => {
const es = new EventSource(withBasePath(`/api/note/annotations/stream?fileId=${fileId}`));
const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`));
es.onmessage = (e: MessageEvent<string>) => {
let ev: AnnotateEvent;

View file

@ -1,7 +1,7 @@
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
// → SSE route (app/api/note/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
// → SSE route (app/api/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling

View file

@ -1,227 +0,0 @@
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
import { decodeUploadFilename } from "@/utils/upload-name";
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
export const DOCVER_MESSAGE_MAX = 2048;
export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const PDF_CONTENT_TYPE = "application/pdf";
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
const FORM_FIELDS = [
"file",
"pdf",
"clientVersionId",
"fields",
"message",
"fileName",
] as const;
export type DocverError = { ok: false; error: string };
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
export function docverError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
export function isDocxBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
}
export function isPdfBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
}
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
export function parseVersionIdParam(raw: string):
| { ok: true; versionId: string }
| DocverError {
const versionId = raw.trim().toLowerCase();
if (!UUID_RE.test(versionId)) {
return { ok: false, error: "versionId ต้องเป็น UUID" };
}
return { ok: true, versionId };
}
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
export function parseDownloadFormat(raw: string | null):
| { ok: true; format: "docx" | "pdf" }
| DocverError {
const format = raw?.trim() ?? "";
if (format === "") return { ok: true, format: "docx" };
if (format === "docx" || format === "pdf") return { ok: true, format };
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
}
function trimOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
function formString(form: FormData, field: string):
| { ok: true; value: string | null }
| DocverError {
const entry = form.get(field);
if (entry === null) return { ok: true, value: null };
if (typeof entry !== "string") {
return { ok: false, error: `field '${field}' ต้องเป็น string` };
}
return { ok: true, value: trimOrNull(entry) };
}
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
export function sanitizeDisplayFileName(raw: string | null): string | null {
if (raw === null) return null;
const base = raw.split(/[\\/]/).pop() ?? raw;
return trimOrNull(base)?.slice(0, 200) ?? null;
}
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
export function sanitizeFilePartName(raw: string): string | null {
return sanitizeDisplayFileName(decodeUploadFilename(raw));
}
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
export function parseFieldsJson(raw: string):
| { ok: true; fields: Record<string, string> | null }
| DocverError {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
const record = parsed as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length > 100) {
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
}
for (const key of keys) {
if (key.length > 200) {
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
}
const value = record[key];
if (typeof value !== "string") {
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
}
if (value.length > 10_000) {
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
}
}
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
}
export type ParsedVersionUploadForm = {
clientVersionId: string | null;
message: string | null;
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
fileName: string | null;
fields: Record<string, string> | null;
};
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
export function parseVersionUploadForm(form: FormData):
| { ok: true; form: ParsedVersionUploadForm }
| DocverError {
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
// downlevelIteration — array ธรรมดาไม่ต้อง)
for (const key of Array.from(form.keys())) {
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
};
}
}
const clientVersionId = formString(form, "clientVersionId");
if (!clientVersionId.ok) return clientVersionId;
if (clientVersionId.value && clientVersionId.value.length > 100) {
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
}
const message = formString(form, "message");
if (!message.ok) return message;
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
}
const rawFields = formString(form, "fields");
if (!rawFields.ok) return rawFields;
let fields: Record<string, string> | null = null;
if (rawFields.value !== null) {
const parsed = parseFieldsJson(rawFields.value);
if (!parsed.ok) return parsed;
fields = parsed.fields;
}
const rawFileName = formString(form, "fileName");
if (!rawFileName.ok) return rawFileName;
return {
ok: true,
form: {
clientVersionId: clientVersionId.value,
message: message.value,
fileName: sanitizeDisplayFileName(rawFileName.value),
fields,
},
};
}
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
const ext = `.${kind}`;
const ascii = (displayName ?? "")
.replace(/[^\x20-\x7E]/g, "")
.replace(/["\\]/g, "")
.replace(/\s+/g, " ")
.trim();
let asciiName: string = DEFAULT_FILE_NAMES[kind];
if (/[A-Za-z0-9]/.test(ascii)) {
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
}
const display = displayName?.trim();
if (display) {
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
}
return `attachment; filename="${asciiName}"`;
}

View file

@ -1,275 +0,0 @@
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
import { and, desc, eq, sql } from "drizzle-orm";
import { Client } from "minio";
import { db } from "@/db";
import {
documents,
documentVersions,
type DocumentRow,
type DocumentVersionRow,
} from "@/db/schema";
import {
contentDispositionValue,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "@/lib/document-versioning";
type MinioConfig = {
endPoint: string;
port: number;
useSSL: boolean;
accessKey: string;
secretKey: string;
bucket: string;
ttlSeconds: number;
};
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
function readConfig(): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT?.trim();
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
if (!endPoint || !accessKey || !secretKey) {
throw new Error(
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
);
}
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
return {
endPoint,
port: Number.isFinite(port) && port > 0 ? port : 9000,
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
accessKey,
secretKey,
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
ttlSeconds:
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
};
}
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
const globalForMinio = globalThis as unknown as {
docverClient?: { key: string; client: Client };
docverBucketReady?: { bucket: string; promise: Promise<void> };
};
function getClient(): { client: Client; config: MinioConfig } {
const config = readConfig();
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
if (globalForMinio.docverClient?.key !== key) {
globalForMinio.docverClient = {
key,
client: new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
}),
};
}
return { client: globalForMinio.docverClient.client, config };
}
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
async function ensureBucket(client: Client, bucket: string): Promise<void> {
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
const promise = (async () => {
if (await client.bucketExists(bucket)) return;
try {
await client.makeBucket(bucket);
} catch (err) {
if (!(await client.bucketExists(bucket))) throw err;
}
})();
globalForMinio.docverBucketReady = { bucket, promise };
promise.catch(() => {
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
if (globalForMinio.docverBucketReady?.promise === promise) {
globalForMinio.docverBucketReady = undefined;
}
});
}
return globalForMinio.docverBucketReady.promise;
}
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
return {
docx: `docver/${versionId}/document.docx`,
pdf: `docver/${versionId}/document.pdf`,
};
}
export type PutVersionInput = {
versionId: string;
docx: { bytes: Buffer; fileName: string | null };
pdf?: { bytes: Buffer; fileName: string | null };
};
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
const { client, config } = getClient();
await ensureBucket(client, config.bucket);
const keys = versionObjectKeys(input.versionId);
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
"Content-Type": DOCX_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
});
if (!input.pdf) return;
try {
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
"Content-Type": PDF_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
});
} catch (err) {
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
throw err;
}
}
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
export async function removeVersionObjects(
versionId: string,
hadPdf: boolean,
): Promise<void> {
try {
const { client, config } = getClient();
const keys = versionObjectKeys(versionId);
await client.removeObject(config.bucket, keys.docx);
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
} catch (err) {
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
}
}
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
* คำนวณ local ล้วนไม่มี network call */
export async function presignVersionUrl(
versionId: string,
format: "docx" | "pdf",
): Promise<{ url: string; expiresAt: string }> {
const { client, config } = getClient();
const key = versionObjectKeys(versionId)[format];
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
return {
url,
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
};
}
// ─── DB ──────────────────────────────────────────────────────────────────────
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
return row ?? null;
}
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
return db
.select()
.from(documentVersions)
.where(eq(documentVersions.documentId, documentId))
.orderBy(desc(documentVersions.versionNumber));
}
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
export async function findVersionForDoc(
docId: string,
versionId: string,
): Promise<DocumentVersionRow | null> {
const [row] = await db
.select({ version: documentVersions })
.from(documentVersions)
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
.limit(1);
return row?.version ?? null;
}
export type InsertVersionInput = {
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
versionId: string;
docId: string;
clientVersionId: string | null;
contentHash: string;
message: string | null;
fields: Record<string, string> | null;
templateId: string | null;
fileName: string | null;
docxObjectKey: string;
pdfObjectKey: string | null;
docxSize: number;
pdfSize: number | null;
userId: string;
userName: string;
};
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
return db.transaction(async (tx) => {
await tx
.insert(documents)
.values({ docId: input.docId })
.onConflictDoNothing({ target: documents.docId });
const [doc] = await tx
.select({ id: documents.id })
.from(documents)
.where(eq(documents.docId, input.docId))
.for("update");
if (!doc) {
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
}
const [latest] = await tx
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
.from(documentVersions)
.where(eq(documentVersions.documentId, doc.id));
const [row] = await tx
.insert(documentVersions)
.values({
id: input.versionId,
documentId: doc.id,
versionNumber: (latest?.max ?? 0) + 1,
clientVersionId: input.clientVersionId,
contentHash: input.contentHash,
message: input.message,
fields: input.fields,
templateId: input.templateId,
fileName: input.fileName,
docxObjectKey: input.docxObjectKey,
pdfObjectKey: input.pdfObjectKey,
docxSize: input.docxSize,
pdfSize: input.pdfSize,
userId: input.userId,
userName: input.userName,
})
.returning();
return row!;
});
}

View file

@ -1,223 +0,0 @@
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { docverError, type DocverError } from "@/lib/document-versioning";
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
export type DocverTokenClaims = {
userid: string;
username: string;
fid: string;
template: string | null;
};
function base64url(input: string): string {
return Buffer.from(input, "utf8").toString("base64url");
}
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
let text: string;
try {
text = Buffer.from(segment, "base64url").toString("utf8");
} catch {
return null;
}
try {
const parsed = JSON.parse(text) as unknown;
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
export function issueDocverToken(
claims: DocverTokenClaims,
secret: string,
ttlSeconds: number,
): string {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const iat = Math.floor(Date.now() / 1000);
const payload = base64url(
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
function claimsFromPayload(
payload: Record<string, unknown>,
): DocverTokenClaims | null {
const { userid, username, fid, template } = payload;
if (typeof userid !== "string" || userid === "") return null;
if (typeof username !== "string" || username === "") return null;
if (typeof fid !== "string" || fid === "") return null;
if (template !== null && typeof template !== "string") return null;
return { userid, username, fid, template };
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
const segments = token.split(".");
if (segments.length !== 3) return { ok: false };
const [headerSegment, payloadSegment, signatureSegment] = segments;
const header = decodeJsonSegment(headerSegment);
if (header === null || header.alg !== "HS256") return { ok: false };
const expected = createHmac("sha256", secret)
.update(`${headerSegment}.${payloadSegment}`)
.digest();
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
const provided = createHash("sha256")
.update(Buffer.from(signatureSegment, "base64url"))
.digest();
const expectedHash = createHash("sha256").update(expected).digest();
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
const payload = decodeJsonSegment(payloadSegment);
if (payload === null) return { ok: false };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
const claims = claimsFromPayload(payload);
if (claims === null) return { ok: false };
return { ok: true, claims };
}
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKey = process.env.DOCVER_API_KEY?.trim();
// const envKey =
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
if (envKey === undefined || envKey === "") {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
}
const provided = request.headers.get("x-api-key");
if (provided === null || provided === "") {
return docverError("API key ไม่ถูกต้อง", 401);
}
const providedHash = createHash("sha256").update(provided).digest();
const envHash = createHash("sha256").update(envKey).digest();
return timingSafeEqual(providedHash, envHash)
? null
: docverError("API key ไม่ถูกต้อง", 401);
}
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
export function requireDocverBearer(
request: Request,
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization");
if (header === null) return denied();
const match = /^Bearer\s+(\S+)$/i.exec(header);
if (match === null) return denied();
const verified = verifyDocverToken(match[1], secret);
if (!verified.ok) return denied();
return { ok: true, claims: verified.claims };
}
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
export function parseTokenRequestBody(
raw: unknown,
): { ok: true; claims: DocverTokenClaims } | DocverError {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
};
}
}
const rawUserid = record.userid;
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
return { ok: false, error: "ต้องระบุ userid (string)" };
}
const userid = rawUserid.trim();
if (userid.length > 100) {
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
}
const rawUsername = record.username;
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
return { ok: false, error: "ต้องระบุ username (string)" };
}
const username = rawUsername.trim();
if (username.length > 200) {
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
}
const rawFid = record.fid;
if (typeof rawFid !== "string" || rawFid.trim() === "") {
return { ok: false, error: "ต้องระบุ fid (string)" };
}
const fid = rawFid.trim();
if (fid.length > 200) {
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
}
const rawTemplate = record.template ?? null;
if (rawTemplate !== null && typeof rawTemplate !== "string") {
return { ok: false, error: "template ต้องเป็น string" };
}
const template = rawTemplate?.trim() || null;
if (template !== null && template.length > 100) {
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
}
return { ok: true, claims: { userid, username, fid, template } };
}

View file

@ -1,4 +1,4 @@
// N10 (PLAN-API.md D1) — auth ของ /api/note/external/* (ผู้เรียก: backend e-Court,
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
// server-to-server ไม่มี browser)
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual

View file

@ -1,4 +1,4 @@
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/external/documents
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
@ -81,7 +81,7 @@ function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
}
/**
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
* validate JSON body ของ POST /api/external/documents (PLAN-API.md D2) — strict กัน
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข

File diff suppressed because it is too large Load diff

View file

@ -1,112 +0,0 @@
import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from 'node:crypto';
/**
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
*/
export function generateHmacSha256(data: string | object, secretKey: string): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
return createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
}
/**
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
*/
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
const calculatedHmac = generateHmacSha256(data, secretKey);
const a = Buffer.from(calculatedHmac, 'hex');
const b = Buffer.from(expectedHmac, 'hex');
if (a.length !== b.length) {
return false;
}
return timingSafeEqual(a, b);
}
// ==========================================
// ตัวอย่างการใช้งาน
// ==========================================
/*
const secret = 'my-super-secret-key';
const jsonData = {
userId: 12345,
action: 'transfer',
amount: 500
};
// 1. สร้าง HMAC
const hmacSignature = generateHmacSha256(jsonData, secret);
console.log('HMAC Signature (Hex):', hmacSignature);
// 2. ตรวจสอบ HMAC
const isValid = verifyHmacSha256(jsonData, secret, hmacSignature);
console.log('Is Signature Valid?:', isValid); // true
*/
/**
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign) ข้อมูลด้วย Ed25519 Private Key (Hex)
* @param message ข้อความดิบที่ต้องการเซ็น (string หรือ Buffer)
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
*/
export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string {
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
// PKCS#8 Header สำหรับ Ed25519 Private Key
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]);
const privateKey = createPrivateKey({
key: pkcs8Buffer,
format: 'der',
type: 'pkcs8',
});
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
const signatureBuffer = sign(null, dataBuffer, privateKey);
return signatureBuffer.toString('hex');
}
/**
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
* @param message ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
* @param signatureHex ลายเซ็นในรูปแบบ Hex String
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
*/
export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
try {
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
// SPKI Header สำหรับ Ed25519 Public Key (32 bytes)
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
const spkiBuffer = Buffer.concat([spkiHeader, rawPublicKey]);
const publicKey = createPublicKey({
key: spkiBuffer,
format: 'der',
type: 'spki',
});
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
const signatureBuffer = Buffer.from(signatureHex, 'hex');
return verify(null, dataBuffer, publicKey, signatureBuffer);
} catch (error) {
return false;
}
}
/**
* ฟังก์ชันสำหรับเรียงคีย์ของ Object ตามตัวอักษรเพื่อไม่ให้โครงสร้าง JSON เพี้ยน
*/
export function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
return Object.keys(obj)
.sort()
.reduce((result: Record<string, unknown>, key: string) => {
result[key] = obj[key];
return result;
}, {});
}

View file

@ -1,758 +0,0 @@
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
// (จัด version เอกสาร)
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
//
// URL ใน paths เป็น path เต็มรวม /api/… ตาม invariant #12 (URL จริง
// /docedit/api/… — servers เติมแค่ basePath ไม่มี /note ซ้อน)
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const ANNOTATION_TYPE_ENUM = [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
] as const;
export const openApiSpec = {
openapi: "3.0.3",
info: {
title: "Document Template & External API ของระบบ Judge's Note",
version: "1.0.0",
description:
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
},
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
tags: [
{
name: "template",
description:
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
},
{
name: "documents",
description:
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " +
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
"และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " +
"ไม่รับจาก URL/multipart form อีก",
},
{
name: "Judge's Note",
description:
"API ภายนอก (N10) — backend e-Court เรียกแบบ server-to-server ของ (Judge's Note)" +
"ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้",
},
],
paths: {
"/api/template/test": {
get: {
tags: ["template"],
summary: "ตรวจสอบว่า service มีชีวิต",
responses: {
200: {
description: "service พร้อมใช้งาน",
content: {
"application/json": {
schema: {
type: "object",
properties: {
ok: { type: "boolean" },
service: { type: "string" },
},
},
},
},
},
},
},
},
"/api/template/extract": {
post: {
tags: ["template"],
summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx",
description:
"อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " +
"ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["file"],
properties: {
file: {
type: "string",
format: "binary",
description: "ไฟล์ .docx ที่มี Content Control",
},
secret: {
type: "string",
description:
"ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property" ,
example:
"super_secret",
},
public_key: {
type: "string",
description:
"Public key ในรูปแบบ HEX <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex",
example:
"66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546",
},
},
},
},
},
},
responses: {
200: {
description: "รายการแท็กพร้อมข้อความ",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ExtractResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/template/render": {
post: {
tags: ["template"],
summary: "สร้างเอกสารจาก template (.docx) + data (JSON)",
description:
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)',
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["template"],
properties: {
template: {
type: "string",
format: "binary",
description: "ไฟล์ .docx ที่เป็น template (มี Content Control)",
},
data: {
type: "string",
format: "binary",
description: 'ไฟล์ JSON เช่น { "blackno": "คดีหมายเลขดำที่", … }',
},
dataJson: {
type: "string",
description:
"JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)<br/>{\"info:\"{\"ticket_data\":\"JSON String\"},\"dc:\"{..},\"dc:\"{\"blackyear\": \"๙๐/๒๕๖๙\",...}}",
example:
'',
},
secret: {
type: "string",
description:
"ค่า secret สำหรับทดสอบการทำ HMAC-SHA256",
example:
'super_secret',
},
private_key: {
type: "string",
description:
"Private key ในรูปแบบ HEX <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex",
example:
'8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1',
},
},
},
},
},
},
responses: {
200: {
description: "ไฟล์ .docx ที่กรอกค่าแล้ว (binary)",
content: {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
schema: { type: "string", format: "binary" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/note/external/documents": {
post: {
tags: ["external"],
summary: "ดึง annotations/bookmarks ของเอกสาร (N10)",
description:
"ถามด้วย fileId (เอกสารเดียว — id ของ e-Court ที่ลิงก์มา) และ/หรือ typeId " +
"(ทุกเอกสารในกลุ่ม) — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข " +
"· response รูปเดียวเสมอ { documents: […] } (กลุ่มว่าง = array ว่าง) " +
"· read-only: snapshot ข้อมูล ณ ตอนเรียก · แต่ละรายการมี url deep link กลับหน้าอ่านของระบบนี้",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ExternalQuery" },
examples: {
"ถามด้วย fileId (เอกสารเดียว)": {
value: { fileId: "DOC-2569-0001" },
},
"ทุกเอกสารในกลุ่ม": {
value: { typeId: "GROUP-01" },
},
"กลุ่ม + เฉพาะ bookmark ของคนใดคนหนึ่ง": {
value: {
typeId: "GROUP-01",
filter: { type: "bookmarks", userId: "u0001" },
},
},
},
},
},
},
responses: {
200: {
description: "bundle ของเอกสารที่ตรงเงื่อนไข",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/DocumentsResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
404: {
description: "ไม่พบเอกสาร (ยังไม่เคยเปิดในระบบ หรือไม่อยู่ในกลุ่ม typeId ที่ระบุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],
summary: "ขอ access_token ของกลุ่ม documents (docver)",
description:
"ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " +
"JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " +
"DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " +
"templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " +
"fid จึงบังคับ",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenRequest" },
examples: {
"ครบทุก claim": {
value: {
userid: "123",
username: "Sorawit Bholsithi",
fid: "3",
template: "T12",
},
},
"ขั้นต่ำ (template เป็นทางเลือก)": {
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
},
},
},
},
},
responses: {
200: {
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/versions": {
post: {
tags: ["documents"],
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " +
"อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " +
"พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " +
"ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " +
"สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " +
"แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)",
security: [{ DocverBearer: [] }],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["file"],
properties: {
file: {
type: "string",
format: "binary",
description: "ไฟล์ .docx ของ version นี้ (บังคับ)",
},
pdf: {
type: "string",
format: "binary",
description:
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
},
clientVersionId: {
type: "string",
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
},
fields: {
type: "string",
description:
'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}',
},
message: {
type: "string",
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
},
fileName: {
type: "string",
description:
"ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)",
},
},
},
},
},
},
responses: {
200: {
description: "version ถูกบันทึกแล้ว",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UploadVersionResult" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
413: {
description: "ไฟล์แนบใหญ่เกิน 100MB",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
get: {
tags: ["documents"],
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " +
"ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " +
"และ hasPdf — ใช้เขียน UI version history ของ add-in",
security: [{ DocverBearer: [] }],
responses: {
200: {
description: "รายการ version เรียงใหม่ → เก่า",
content: {
"application/json": {
schema: {
type: "array",
items: { $ref: "#/components/schemas/VersionRecord" },
},
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
404: {
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/versions/{versionId}/download-link": {
get: {
tags: ["documents"],
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " +
"(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " +
"ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " +
"ใน version ที่ไม่มีสำเนา = 404",
security: [{ DocverBearer: [] }],
parameters: [
{
name: "versionId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "id ของ version (field versionId ใน VersionRecord)",
},
{
name: "format",
in: "query",
required: false,
schema: { type: "string", enum: ["docx", "pdf"], default: "docx" },
description: "ชนิดไฟล์ที่จะดาวน์โหลด (ไม่ส่ง = docx)",
},
],
responses: {
200: {
description: "presigned URL พร้อมเวลาหมดอายุ",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/DownloadLink" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
404: {
description:
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
},
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "x-api-key",
description:
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง)",
},
DocverBearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " +
"กับ endpoint อื่นของกลุ่ม documents ทุกตัว",
},
},
responses: {
BadRequest: {
description:
"request ไม่ถูกต้อง (field หาย / ชนิดไฟล์ผิด / ค่า enum ไม่รู้จัก / " +
"JSON ผิด format / ไฟล์แนบใหญ่เกิน 100MB)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
DocverUnauthorized: {
description:
"ไม่ได้แนบ Authorization: Bearer หรือ token ไม่ถูกต้อง/หมดอายุ (กลุ่ม documents — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
ServerError: {
description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
schemas: {
Error: {
type: "object",
properties: {
success: { type: "boolean", example: false },
error: { type: "string" },
},
},
TokenRequest: {
type: "object",
description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400",
required: ["userid", "username", "fid"],
properties: {
userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" },
username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" },
fid: {
type: "string",
description:
"fileId ของ e-Court (บังคับ, ≤ 200 ตัวอักษร) — เป็น docId ของทุก operation ตรง ๆ",
},
template: {
type: "string",
nullable: true,
description:
"รหัส template ที่ใช้สร้างเอกสาร — กลายเป็น templateId ตอนอัปโหลด version",
},
},
},
TokenResponse: {
type: "object",
description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)",
properties: {
access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" },
token_type: { type: "string", example: "Bearer" },
expires_in: {
type: "integer",
description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)",
},
},
},
ExtractResponse: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: {
type: "object",
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
additionalProperties: { type: "string" },
example: {
blackno: "คดีหมายเลขดำที่",
court: "ศาลปกครองสุพรรณบุรี",
},
},
},
},
ExternalQuery: {
type: "object",
description:
"ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง (ส่งทั้งคู่ได้ = AND เหลือเอกสารเดียว)",
properties: {
fileId: {
type: "string",
description: "id เอกสารของ e-Court — ตรงกับที่ส่งมากับลิงก์ ?fileId= ตอนเปิดเอกสาร",
},
typeId: {
type: "string",
description: "รหัสกลุ่มเอกสาร — คืนทุกไฟล์ในกลุ่มนี้ (เรียงตามวันที่สร้าง)",
},
filter: {
type: "object",
properties: {
type: {
type: "string",
enum: ["bookmarks", "annotations"],
description: "เลือกเฉพาะชนิดรายการ (ไม่ส่ง = ทั้งสอง ส่วนที่ไม่เลือกคืนเป็น array ว่าง)",
},
userId: {
description: "กรองตามเจ้าของรายการ (string เดี่ยวหรือ array)",
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "string" } },
],
},
annotationTypes: {
description: "กรองชนิดของ annotation — มีผลเฉพาะเมื่อรวม annotations",
oneOf: [
{ type: "string", enum: [...ANNOTATION_TYPE_ENUM] },
{
type: "array",
items: { type: "string", enum: [...ANNOTATION_TYPE_ENUM] },
},
],
},
},
},
},
},
DocumentsResponse: {
type: "object",
properties: {
documents: {
type: "array",
items: { $ref: "#/components/schemas/DocumentBundle" },
},
},
},
DocumentBundle: {
type: "object",
description: "field ของเอกสารอยู่ระดับเดียวกับ annotations/bookmarks (bundle ยุบชั้น)",
properties: {
id: { type: "string", format: "uuid" },
externalFileId: {
type: "string",
nullable: true,
description: "id ของ e-Court (null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
},
title: { type: "string" },
typeId: { type: "string", nullable: true },
createdAt: { type: "string", format: "date-time" },
annotations: {
type: "array",
items: { $ref: "#/components/schemas/ExternalAnnotation" },
},
bookmarks: {
type: "array",
items: { $ref: "#/components/schemas/ExternalBookmark" },
},
},
},
ExternalAnnotation: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
fileId: { type: "string", format: "uuid" },
pageIndex: { type: "integer", description: "0-based" },
userId: { type: "string" },
userName: { type: "string" },
type: { type: "string", enum: [...ANNOTATION_TYPE_ENUM] },
data: {
type: "object",
description:
"ข้อมูลเฉพาะชนิด (พิกัดเป็น % ของขนาดหน้า) เช่น HIGHLIGHT { text, color, rects }, COMMENT { message, position }",
},
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
resolved: { type: "boolean" },
resolvedAt: { type: "string", format: "date-time", nullable: true },
url: {
type: "string",
description: "deep link กลับหน้าอ่าน พร้อม ?ann= ของรายการนี้",
},
replies: {
type: "array",
items: { $ref: "#/components/schemas/ExternalReply" },
},
},
},
ExternalBookmark: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
fileId: { type: "string", format: "uuid" },
pageIndex: { type: "integer", description: "0-based" },
userId: { type: "string" },
userName: { type: "string" },
title: { type: "string" },
parentId: {
type: "string",
nullable: true,
description: "null = ระดับบนสุด (มีค่า = sub ของ bookmark นั้น)",
},
note: { type: "string", nullable: true },
yOffset: { type: "number", description: "ตำแหน่งบนหน้า (% ของความสูง)" },
createdAt: { type: "string", format: "date-time" },
url: {
type: "string",
description: "deep link กลับหน้าอ่าน พร้อม ?page= 1-based ของหน้านี้",
},
},
},
ExternalReply: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
annotationId: { type: "string", format: "uuid" },
userId: { type: "string" },
userName: { type: "string" },
message: { type: "string" },
createdAt: { type: "string", format: "date-time" },
},
},
UploadVersionResult: {
type: "object",
description: "ตอบกลับหลังอัปโหลด version สำเร็จ",
properties: {
hasPdf: { type: "boolean", description: "true = version นี้มีสำเนา PDF" },
versionId: { type: "string", format: "uuid" },
versionNumber: {
type: "integer",
description: "เลข version ที่ server จ่าย (ค่าจริง — ไม่ใช่ที่ client คาด)",
},
},
},
VersionRecord: {
type: "object",
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
properties: {
versionId: { type: "string", format: "uuid" },
versionNumber: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
createdByUserId: { type: "string" },
createdByUserName: { type: "string" },
contentHash: {
type: "string",
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
},
fields: {
type: "object",
nullable: true,
description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)",
additionalProperties: { type: "string" },
},
message: { type: "string", nullable: true, description: "commit message" },
hasPdf: { type: "boolean" },
},
},
DownloadLink: {
type: "object",
properties: {
url: { type: "string", description: "presigned URL ของ MinIO (GET โดยตรง)" },
expiresAt: { type: "string", format: "date-time" },
},
},
},
},
} as const;

View file

@ -1,5 +1,5 @@
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
// ใช้ 2 จุด: /api/note/pdf (viewer) และ /api/note/annotations/export — กัน CORS ฝั่ง browser
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";

View file

@ -8,9 +8,7 @@ import path from "node:path";
export class PdfPathError extends Error {}
export function resolvePublicPdfPath(pdfUrl: string): string {
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
const publicDir = path.resolve(process.cwd(), "public", "note");
const publicDir = path.resolve(process.cwd(), "public");
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));

View file

@ -9,11 +9,10 @@ export function middleware(req: NextRequest) {
if (!user) {
const url = req.nextUrl.clone();
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /note/login มี query เกินจำเป็น
// (path ตรงนี้ตัด basePath แล้ว จึงขึ้นต้น /note/… เสมอ / NextURL ใส่ basePath คืนตอน
// redirect เอง — invariant #12)
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /login มี query เกินจำเป็น
// (path ตรงนี้ตัด basePath แล้ว / NextURL ใส่ basePath คืนตอน redirect เอง — invariant #12)
const next = url.pathname + url.search;
url.pathname = "/note/login";
url.pathname = "/login";
url.search = "";
if (next !== "/") url.searchParams.set("next", next);
return NextResponse.redirect(url);
@ -21,22 +20,10 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
// + public/ ราก (fonts, favicon)
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
export const config = {
matcher: [
"/((?!note/login|api/note|api/template|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
],
};

View file

@ -1,38 +1,14 @@
const basePath = "/docedit";
// prefix ระดับแอปของ judes-note ใน route tree (app/note/**) + statics (public/note/**)
// — basePath /docedit เป็นของกลางแชร์กับแอปพี่น้อง (เช่น wordaddin ในอนาคต)
// URL ปลายทางจึงยังเป็น /docedit/note/… เหมือนเดิมทุกจุด
const appPath = "/note";
const basePath = "/docedit/note";
/** @type {import('next').NextConfig} */
const nextConfig = {
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
output: "standalone",
// serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath
// ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/login") ส่วน fetch/EventSource/
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note)
// serve ใต้ /docedit/note — <Link>/next/navigation เติมให้เอง แต่ fetch/EventSource/
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts)
basePath,
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
env: { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_APP_PATH: appPath },
// ปิด redirect trailing slash อัตโนมัติของ Next (308 /docedit → /docedit/
// และ /docedit/note/ → /docedit/note) — deploy หลัง IIS แล้ว redirect พวกนี้
// ตีกับ rewrite rule ของ IIS จนกลายเป็น ping-pong loop; ปิดให้หน้า/ไฟล์ทุกอย่าง
// serve ได้ทั้งแบบมี/ไม่มี slash ฝั่ง proxy เป็นคนกำหนดเอง
skipTrailingSlashRedirect: true,
// จับเฉพาะ root ของ host ("/" แท้ ๆ — basePath:false ทำให้ rule นี้ไม่จับ
// /docedit) เผื่อ IIS ส่ง path "/" เข้า process: ส่งตรงไป /docedit/note เลย
// ส่วน /docedit และ /docedit/ จับที่ app/page.tsx แทน (ส่งต่อ query เอง —
// รักษาสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId=)
async redirects() {
return [
{
source: "/",
destination: `${basePath}${appPath}`,
basePath: false, // ป้องกัน basePath ซ้อน (destination เขียนเต็มแล้ว)
permanent: false,
},
];
},
env: { NEXT_PUBLIC_BASE_PATH: basePath },
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
experimental: {
serverActions: {

8402
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,40 +8,29 @@
"test": "vitest run",
"start": "next start",
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
"dev:https": "next dev --experimental-https",
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
"postinstall": "node scripts/patch-pdf-worker.mjs"
},
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",
"@xmldom/xmldom": "^0.9.12",
"docx-templates": "^4.15.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.42.0",
"minio": "^8.0.7",
"next": "^14.2.35",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"pizzip": "^3.3.0",
"pngjs": "^7.0.0",
"postgres": "^3.4.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"swagger-ui-dist": "^5.33.1",
"utif2": "^4.1.0",
"xpath": "^0.0.34"
"utif2": "^4.1.0"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/office-js": "^1.0.612",
"@types/node": "^26.5.0",
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"office-addin-debugging": "^7.0.1",
"office-addin-dev-settings": "^4.0.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",

7543
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
allowBuilds:
'@azure/msal-node-extensions': set this to true or false
'@microsoft/m365agentstoolkit-cli': set this to true or false
'@scarf/scarf': set this to true or false
esbuild: set this to true or false
keytar: set this to true or false

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,41 +0,0 @@
{
"info": {
"ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
},
"dc": {
"Interpleader": true,
"docname": "",
"docabbr": "(ต. ๑๒)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"panels": [
{
"pname": "นายวิรุฬห์ จินตนะกุล",
"pmname": "ตุลาการศาลปกครองกลาง",
"pm": "ตุลาการเจ้าของสำนวน"
},
{
"pname": "นายสัมฤทธิ์ อ่อนคำ",
"pmname": "ตุลาการศาลปกครองสูงสุด"
}
],
"pnameT":"",
"pmnameT":"",
"pmT":"",
"acc_name":""
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
}
}

View file

@ -1,51 +0,0 @@
{
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง",
"pmname1": "ตุลาการศาลปกครองกลาง",
"pmname2": "ตุลาการศาลปกครองสูงสุด"
},
"bookmark": {
"signature1": "",
"signature2": ""
},
"properties": {
"standard": {
"title": " ",
"subject": "",
"creator": "oom",
"keywords": "",
"lastModifiedBy": "Sorawit Bholsithi",
"revision": "61",
"created": "2026-10-15T05:18:00.000Z",
"modified": "2026-10-21T04:59:00.000Z"
},
"extended": {
"template": "Normal.dotm",
"totalTime": 345,
"pages": 1,
"words": 42,
"characters": 493,
"charactersWithSpaces": 534,
"lines": 4,
"paragraphs": 1,
"application": "Microsoft Office Word",
"appVersion": "16.0000",
"company": "",
"docSecurity": 0
},
"custom": {
"ticket_data": "{\"exp\":1792667826,\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}",
"HmacSha256": "791987c9274493db02561f4ec16b83d05c89766b54661da615e8e02db293d1d4",
"Ed25519": "7ef8b0f2a03a52c49f8c348e3c392f448b71c8462ee860342b0e06a91b555f860cad5649c648dc466982017683f3718caff139c85e17e952ee1570ecfc021e08"
}
}
}

View file

@ -1,50 +0,0 @@
{
"info": {
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
},
"dc": {
"Interpleader": false,
"CommandTo": true,
"SendTo": false,
"AdditionCommand" : true,
"Warning":false,
"docname":"คำสั่งศาล",
"doctype":"(ต. ๔๗.๑)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"doctitle":"คำสั่งศาลถึง",
"pname":"สมหญิง",
"courtname":"ศาลปกครองสุพรรณบุรี",
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
"pname2":"ชื่อของ pname2"
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"commto" : "นายสมชาย จรดปลายเท้า",
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
"groupname":"ผู้อำนวยการโรงเรียน",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
"remarkhead":"หัวของ Remark",
"remark":"This is remark Text bla bla "
}
}

View file

@ -1,52 +0,0 @@
{
"info": {
"ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
},
"dc": {
"Interpleader": true,
"CommandTo": true,
"SendTo": false,
"AdditionCommand" : true,
"Warning":true,
"docname":"คำสั่งเรียกพยานบุคคล",
"doctype":"(ต. ๖)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"doctitle":"คำสั่งถึง",
"pname":"สมหญิง",
"courtname":"ศาลปกครองสุพรรณบุรี",
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
"pname2":"ชื่อของ pname2",
"remarkhead":"หัวของ Remark"
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"commto" : "นายสมชาย จรดปลายเท้า",
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
"groupname":"ผู้อำนวยการโรงเรียน",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
"remark":"This is remark Text bla bla "
}
}

Some files were not shown because too many files have changed in this diff Show more