Compare commits
39 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677de2fc29 | ||
|
|
ce646ffc20 | ||
|
|
b8ff622999 | ||
|
|
37b18c58c2 | ||
|
|
4200176239 | ||
|
|
7cd8939789 | ||
|
|
cffc571da5 | ||
|
|
69d83ea493 | ||
|
|
8ebe5f8644 | ||
|
|
50af6c10fa | ||
|
|
a3d14fd58d | ||
|
|
6ce071ee14 | ||
|
|
ab62a2b3e2 | ||
|
|
c06da83f92 | ||
|
|
86353cbe57 | ||
|
|
0b2c9e6343 | ||
|
|
aad6f155fe | ||
|
|
98b370c925 | ||
|
|
ac6c0dc71e | ||
|
|
280cc2da40 | ||
|
|
949861ba86 | ||
|
|
ed8493004c | ||
|
|
765946f0d5 | ||
|
|
6daee15851 | ||
|
|
5917c0532c | ||
|
|
a172b111a3 | ||
|
|
1aeaae38f1 | ||
|
|
4bcd01a95f | ||
|
|
808233191d | ||
|
|
2b70a50c62 | ||
|
|
dc851fbb73 | ||
|
|
33670d0b18 | ||
|
|
c401f4deb7 | ||
|
|
7a146d48be | ||
|
|
b41816e2b6 | ||
|
|
073f72db7d | ||
|
|
8cc15ea75d | ||
|
|
b123ecc9b2 | ||
|
|
c08aaab1ed |
142 changed files with 28858 additions and 430 deletions
|
|
@ -11,6 +11,7 @@ paths:
|
||||||
- ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path
|
- ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path
|
||||||
- `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100)
|
- `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100)
|
||||||
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
|
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
|
||||||
|
- `rectsInSweptPath(rects, start, end)` (กรอง selection rects ตามเส้นทางลาก): บรรทัดแรก = จุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้าย = ต้นบรรทัด→จุดจบ, **บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง** — ช่วงคอลัมน์ยึดจากขอบซ้าย/ขวาจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย (ไม่ใช่พิกัดจุดลากตรง ๆ — ลากชัน/ลาก margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มตาม native) + ขยายริมละ 2×lineTol ให้ทน justify gap ระหว่าง rect (pad อิงความสูงบรรทัด ตาม zoom) — span ประกอบที่ห่างเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ถูกตัดทิ้ง (bug report 2026-10-05 + code review ข้อ 1)
|
||||||
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
|
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
|
||||||
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
|
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
|
||||||
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)**
|
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)**
|
||||||
|
|
|
||||||
|
|
@ -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
|
- `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 (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
|
- ตาราง `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` ก่อน
|
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
|
||||||
- `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 หน้า)
|
- `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 ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
- 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.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 — ไม่มีตารางกลุ่ม
|
- `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/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/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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
- "components/pdf/**"
|
- "components/pdf/**"
|
||||||
- "app/annotate/**"
|
- "app/note/annotate/**"
|
||||||
---
|
---
|
||||||
|
|
||||||
# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)
|
# 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 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)
|
- Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak)
|
||||||
- Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง
|
- Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง
|
||||||
- **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)
|
- **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)
|
||||||
- **ข้อความจาก 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) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด
|
- **ข้อความจาก 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 ไทย**
|
- คีย์ลัด 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 ต้องไม่โดนลบ)
|
- `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
|
- 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/` **ไม่ใช่สำเนาตรง ๆ** ของ 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)`
|
- 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)`
|
||||||
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
|
- 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>`
|
- 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)
|
- 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 จึงใช้ไม่ได้)
|
- 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)
|
- คลิกเลือกบนหน้า: 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 โผล่ที่ตำแหน่งเคอร์เซอร์พอดี ซ้อนเป็นสองกล่อง)
|
- 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/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/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 ได้
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ paths:
|
||||||
|
|
||||||
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
|
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
|
||||||
|
|
||||||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
|
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
|
||||||
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
|
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
|
||||||
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case
|
- 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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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 ทางเลือก
|
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
|
||||||
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
||||||
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
|
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
|
||||||
|
|
||||||
## ข้อควรระวัง
|
## ข้อควรระวัง
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ npm-debug.log*
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
|
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
|
||||||
public/uploads
|
public/note/uploads
|
||||||
|
|
|
||||||
49
.env.example
49
.env.example
|
|
@ -5,3 +5,52 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||||
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
|
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
|
||||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||||
# EXTERNAL_API_KEY=
|
# EXTERNAL_API_KEY=
|
||||||
|
|
||||||
|
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||||
|
DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit
|
||||||
|
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||||
|
EXTERNAL_API_KEY='secret-api-key-1234'
|
||||||
|
# ที่เก็บไฟล์เทมเพลต(docx)
|
||||||
|
TMPL_PATH='./public/tmpl'
|
||||||
|
|
||||||
|
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
|
||||||
|
SECRET="super_secret"
|
||||||
|
PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"
|
||||||
|
PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546"
|
||||||
|
# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data)
|
||||||
|
TICKET_SEC=300
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
|
||||||
|
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
|
||||||
|
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
|
||||||
|
# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ —
|
||||||
|
# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||||
|
MINIO_ENDPOINT=127.0.0.1
|
||||||
|
MINIO_PORT=19000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
# MINIO_BUCKET=docver
|
||||||
|
# DOCVER_LINK_TTL_SECONDS=900
|
||||||
|
|
||||||
|
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
|
||||||
|
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||||
|
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
|
||||||
|
DOCVER_API_KEY=
|
||||||
|
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
|
||||||
|
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
|
||||||
|
DOCVER_TOKEN_SECRET=
|
||||||
|
# DOCVER_TOKEN_TTL_SECONDS=3600
|
||||||
|
|
||||||
|
# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token
|
||||||
|
# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp
|
||||||
|
# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง
|
||||||
|
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
||||||
|
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
||||||
|
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
||||||
|
# ADDIN_TICKET_SECRET=super_secret
|
||||||
|
# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง
|
||||||
|
# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน)
|
||||||
|
# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -29,7 +29,16 @@ next-env.d.ts
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||||
public/uploads/
|
public/note/uploads/
|
||||||
|
|
||||||
|
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
|
||||||
|
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
|
||||||
|
public/wordaddin/office-js/
|
||||||
|
|
||||||
|
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
|
||||||
|
/certs/
|
||||||
|
|
||||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||||
docker-compose.test.yml
|
docker-compose.test.yml
|
||||||
|
|
||||||
|
certificates
|
||||||
86
CLAUDE.md
86
CLAUDE.md
File diff suppressed because one or more lines are too long
77
README.md
77
README.md
|
|
@ -19,7 +19,7 @@
|
||||||
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
|
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
|
||||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
|
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` |
|
||||||
|
|
||||||
## ขั้นตอน Setup จากศูนย์
|
## ขั้นตอน Setup จากศูนย์
|
||||||
|
|
||||||
|
|
@ -31,25 +31,30 @@ npm install
|
||||||
|
|
||||||
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
|
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
|
||||||
|
|
||||||
### 2. รัน PostgreSQL container
|
### 2. รัน PostgreSQL + MinIO ด้วย docker compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name judes-note-db \
|
docker compose up -d
|
||||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
|
|
||||||
-p 54322:5432 \
|
|
||||||
-v judes-note-db-data:/var/lib/postgresql/data \
|
|
||||||
postgres:17-alpine
|
|
||||||
```
|
```
|
||||||
|
|
||||||
ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
|
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio`
|
||||||
|
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
|
||||||
|
|
||||||
|
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
|
||||||
|
|
||||||
### 3. สร้างไฟล์ `.env`
|
### 3. สร้างไฟล์ `.env`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||||
# N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||||
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||||
# EXTERNAL_API_KEY=
|
# 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`)
|
(เทียบกับ `.env.example`)
|
||||||
|
|
@ -62,13 +67,13 @@ npx drizzle-kit push
|
||||||
|
|
||||||
### 5. Seed ไฟล์ตัวอย่าง
|
### 5. Seed ไฟล์ตัวอย่าง
|
||||||
|
|
||||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
ไฟล์ตัวอย่างอยู่ที่ `public/note/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run seed-samples
|
npm run seed-samples
|
||||||
```
|
```
|
||||||
|
|
||||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/note/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||||
|
|
||||||
### 6. รัน dev server
|
### 6. รัน dev server
|
||||||
|
|
||||||
|
|
@ -76,7 +81,7 @@ npm run seed-samples
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
|
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`)
|
||||||
|
|
||||||
## การใช้งานบนหน้า annotate
|
## การใช้งานบนหน้า annotate
|
||||||
|
|
||||||
|
|
@ -94,10 +99,10 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
|
||||||
|
|
||||||
## API สำหรับระบบภายนอก (N10)
|
## API สำหรับระบบภายนอก (N10)
|
||||||
|
|
||||||
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`
|
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`
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
// POST /docedit/api/note/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
||||||
// headers: x-api-key + content-type: application/json
|
// headers: x-api-key + content-type: application/json
|
||||||
{
|
{
|
||||||
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
|
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
|
||||||
|
|
@ -118,13 +123,35 @@ Read-only API ให้ **backend ของ e-Court** เรียกแบบ s
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
|
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
|
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"typeId":"CASE-2569-0001"}' \
|
-d '{"typeId":"CASE-2569-0001"}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API จัด version เอกสาร (docver — stand-alone)
|
||||||
|
|
||||||
|
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
|
||||||
|
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
|
||||||
|
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
|
||||||
|
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||||
|
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
|
||||||
|
|
||||||
|
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
|
||||||
|
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||||
|
|
||||||
|
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
|
||||||
|
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
|
||||||
|
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
|
||||||
|
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| อาการ | สาเหตุ/ทางแก้ |
|
| อาการ | สาเหตุ/ทางแก้ |
|
||||||
|
|
@ -132,12 +159,13 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
||||||
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
|
| 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 |
|
| หน้าเว็บ 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 แล้วรันใหม่ |
|
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
|
||||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||||
|
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
|
||||||
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
|
| 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/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/note/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
|
||||||
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
|
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
|
||||||
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
|
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
|
||||||
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
|
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
|
||||||
|
|
@ -148,15 +176,18 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
||||||
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
||||||
db/index.ts postgres.js + drizzle (prepare: false)
|
db/index.ts postgres.js + drizzle (prepare: false)
|
||||||
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
|
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/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
||||||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||||
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||||
app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
|
||||||
app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||||
|
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||||
components/sidebar/ AnnotationSidebar
|
components/sidebar/ AnnotationSidebar
|
||||||
public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||||
public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
public/note/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -445,8 +445,8 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
||||||
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
||||||
*/
|
*/
|
||||||
function revalidateFilePages(fileId: string) {
|
function revalidateFilePages(fileId: string) {
|
||||||
revalidatePath(`/annotate/${fileId}`);
|
revalidatePath(`/note/annotate/${fileId}`);
|
||||||
revalidatePath(`/annotate/${fileId}/summary`);
|
revalidatePath(`/note/annotate/${fileId}/summary`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,6 @@ export async function logout(): Promise<void> {
|
||||||
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
||||||
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||||
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
||||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้ารายการที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||||
redirect("/login");
|
redirect("/note/login");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
||||||
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
// เขียนไฟล์**ใหม่**ที่ public/note/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
|
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -13,9 +13,10 @@ import { db } from "@/db";
|
||||||
import { files } from "@/db/schema";
|
import { files } from "@/db/schema";
|
||||||
import { normDocumentGroup } from "@/utils/external-file";
|
import { normDocumentGroup } from "@/utils/external-file";
|
||||||
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
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 MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
const UPLOAD_DIR = path.join(process.cwd(), "public", "note", "uploads");
|
||||||
|
|
||||||
export type UploadFileResult =
|
export type UploadFileResult =
|
||||||
| { ok: true; fileId: string; title: string }
|
| { ok: true; fileId: string; title: string }
|
||||||
|
|
@ -67,7 +68,10 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
||||||
if (file.size === 0) {
|
if (file.size === 0) {
|
||||||
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
||||||
}
|
}
|
||||||
const ext = allowedExt(file.name);
|
// ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) —
|
||||||
|
// ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title
|
||||||
|
const fileName = decodeUploadFilename(file.name);
|
||||||
|
const ext = allowedExt(fileName);
|
||||||
if (!ext) {
|
if (!ext) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -93,7 +97,7 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
||||||
const diskPath = path.join(UPLOAD_DIR, diskName);
|
const diskPath = path.join(UPLOAD_DIR, diskName);
|
||||||
await writeFile(diskPath, bytes);
|
await writeFile(diskPath, bytes);
|
||||||
|
|
||||||
const title = sanitizeTitle(file.name);
|
const title = sanitizeTitle(fileName);
|
||||||
const pdfUrl = `/uploads/${diskName}`;
|
const pdfUrl = `/uploads/${diskName}`;
|
||||||
const typeId = normDocumentGroup(formData.get("typeId"));
|
const typeId = normDocumentGroup(formData.get("typeId"));
|
||||||
try {
|
try {
|
||||||
|
|
@ -104,6 +108,6 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/note");
|
||||||
return { ok: true, fileId: id, title };
|
return { ok: true, fileId: id, title };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
app/api/documents/token/route.ts
Normal file
97
app/api/documents/token/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
|
||||||
|
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
||||||
|
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||||
|
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
||||||
|
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
||||||
|
// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
||||||
|
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
|
||||||
|
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in)
|
||||||
|
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
||||||
|
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
|
||||||
|
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
||||||
|
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
||||||
|
import { docverError } from "@/lib/document-versioning";
|
||||||
|
import {
|
||||||
|
addinTicketSecret,
|
||||||
|
parseTicketTokenBody,
|
||||||
|
ticketToDocverClaims,
|
||||||
|
verifyAddinTicket,
|
||||||
|
} from "@/lib/addin-ticket";
|
||||||
|
import {
|
||||||
|
issueDocverToken,
|
||||||
|
parseTokenRequestBody,
|
||||||
|
requireDocverApiKey,
|
||||||
|
type DocverTokenClaims,
|
||||||
|
} from "@/lib/docver-token";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */
|
||||||
|
const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
return request.headers.get("x-api-key") !== null
|
||||||
|
? postWithApiKey(request)
|
||||||
|
: postWithTicket(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
||||||
|
async function postWithApiKey(request: Request): Promise<Response> {
|
||||||
|
const apiKeyFail = requireDocverApiKey(request);
|
||||||
|
if (apiKeyFail !== null) return apiKeyFail;
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await request.json();
|
||||||
|
} catch {
|
||||||
|
return docverError("body ต้องเป็น JSON", 400);
|
||||||
|
}
|
||||||
|
const parsed = parseTokenRequestBody(raw);
|
||||||
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
|
return issueToken(parsed.claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
||||||
|
async function postWithTicket(request: Request): Promise<Response> {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await request.json();
|
||||||
|
} catch {
|
||||||
|
return docverError("body ต้องเป็น JSON", 400);
|
||||||
|
}
|
||||||
|
const parsed = parseTicketTokenBody(raw);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
// missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน)
|
||||||
|
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
|
||||||
|
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
|
||||||
|
}
|
||||||
|
const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
|
||||||
|
if (!verified.ok) return docverError(TICKET_DENIED, 401);
|
||||||
|
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
||||||
|
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
||||||
|
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||||
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
|
// const secret =
|
||||||
|
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||||
|
|
||||||
|
if (secret === undefined || secret === "") {
|
||||||
|
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||||
|
}
|
||||||
|
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||||
|
const ttlSeconds =
|
||||||
|
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
||||||
|
|
||||||
|
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: ttlSeconds,
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
|
||||||
|
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
||||||
|
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
|
||||||
|
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
|
||||||
|
import {
|
||||||
|
docverError,
|
||||||
|
parseDownloadFormat,
|
||||||
|
parseVersionIdParam,
|
||||||
|
} from "@/lib/document-versioning";
|
||||||
|
import { requireDocverBearer } from "@/lib/docver-token";
|
||||||
|
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { versionId: string } },
|
||||||
|
) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||||
|
const docId = auth.claims.fid;
|
||||||
|
const versionId = parseVersionIdParam(params.versionId);
|
||||||
|
if (!versionId.ok) return docverError(versionId.error, 400);
|
||||||
|
let format: string | null = null;
|
||||||
|
try {
|
||||||
|
format = new URL(request.url).searchParams.get("format");
|
||||||
|
} catch {
|
||||||
|
return docverError("URL ของ request ไม่ถูกต้อง", 400);
|
||||||
|
}
|
||||||
|
const parsedFormat = parseDownloadFormat(format);
|
||||||
|
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await findVersionForDoc(docId, versionId.versionId);
|
||||||
|
if (!row) {
|
||||||
|
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||||
|
}
|
||||||
|
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
|
||||||
|
return docverError("version นี้ไม่มีสำเนา PDF", 404);
|
||||||
|
}
|
||||||
|
const link = await presignVersionUrl(row.id, parsedFormat.format);
|
||||||
|
return new Response(JSON.stringify(link), {
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docver: สร้าง download link ล้ม", err);
|
||||||
|
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/api/documents/versions/route.ts
Normal file
170
app/api/documents/versions/route.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions
|
||||||
|
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
|
||||||
|
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
|
||||||
|
// Authorization: Bearer ทุก request — identity (userid/username), templateId และ
|
||||||
|
// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด
|
||||||
|
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { toDocumentVersionDTO } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
docverError,
|
||||||
|
DOCVER_MAX_UPLOAD_BYTES,
|
||||||
|
DOCX_CONTENT_TYPE,
|
||||||
|
isDocxBytes,
|
||||||
|
isPdfBytes,
|
||||||
|
parseVersionUploadForm,
|
||||||
|
sanitizeFilePartName,
|
||||||
|
} from "@/lib/document-versioning";
|
||||||
|
import { requireDocverBearer } from "@/lib/docver-token";
|
||||||
|
import {
|
||||||
|
findDocumentByDocId,
|
||||||
|
insertVersionRecord,
|
||||||
|
listVersionsByDocId,
|
||||||
|
putVersionObjects,
|
||||||
|
removeVersionObjects,
|
||||||
|
versionObjectKeys,
|
||||||
|
} from "@/lib/docver-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||||
|
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||||
|
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||||
|
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||||
|
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||||
|
const docId = auth.claims.fid;
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return docverError("body ต้องเป็น multipart/form-data", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseVersionUploadForm(form);
|
||||||
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
|
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
|
||||||
|
}
|
||||||
|
if (file.size === 0) {
|
||||||
|
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
|
||||||
|
}
|
||||||
|
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
|
||||||
|
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||||
|
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
|
||||||
|
}
|
||||||
|
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
|
||||||
|
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
|
||||||
|
const rawPdf = form.get("pdf");
|
||||||
|
let pdf: File | null = null;
|
||||||
|
if (rawPdf instanceof File && rawPdf.size > 0) {
|
||||||
|
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||||
|
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
|
||||||
|
}
|
||||||
|
pdf = rawPdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
let docxBytes: Buffer;
|
||||||
|
let pdfBytes: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
docxBytes = Buffer.from(await file.arrayBuffer());
|
||||||
|
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
|
||||||
|
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||||
|
}
|
||||||
|
if (!isDocxBytes(docxBytes)) {
|
||||||
|
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
|
||||||
|
}
|
||||||
|
if (pdfBytes && !isPdfBytes(pdfBytes)) {
|
||||||
|
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
|
||||||
|
const versionId = randomUUID();
|
||||||
|
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
|
||||||
|
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
|
||||||
|
const docxDisplayName =
|
||||||
|
parsed.form.fileName ?? sanitizeFilePartName(file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putVersionObjects({
|
||||||
|
versionId,
|
||||||
|
docx: { bytes: docxBytes, fileName: docxDisplayName },
|
||||||
|
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
|
||||||
|
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = versionObjectKeys(versionId);
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
row = await insertVersionRecord({
|
||||||
|
versionId,
|
||||||
|
docId,
|
||||||
|
clientVersionId: parsed.form.clientVersionId,
|
||||||
|
contentHash,
|
||||||
|
message: parsed.form.message,
|
||||||
|
fields: parsed.form.fields,
|
||||||
|
templateId: auth.claims.template,
|
||||||
|
fileName: docxDisplayName,
|
||||||
|
docxObjectKey: keys.docx,
|
||||||
|
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
||||||
|
docxSize: docxBytes.length,
|
||||||
|
pdfSize: pdfBytes?.length ?? null,
|
||||||
|
userId: auth.claims.userid,
|
||||||
|
userName: auth.claims.username,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
||||||
|
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
|
||||||
|
await removeVersionObjects(versionId, pdfBytes !== null);
|
||||||
|
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
hasPdf: pdfBytes !== null,
|
||||||
|
versionId: row.id,
|
||||||
|
versionNumber: row.versionNumber,
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
||||||
|
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const docId = auth.claims.fid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = await findDocumentByDocId(docId);
|
||||||
|
if (!doc) {
|
||||||
|
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
|
||||||
|
}
|
||||||
|
const rows = await listVersionsByDocId(doc.id);
|
||||||
|
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docver: โหลด version history ล้ม", err);
|
||||||
|
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,9 @@ import { db } from "@/db";
|
||||||
import { files } from "@/db/schema";
|
import { files } from "@/db/schema";
|
||||||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||||
|
import { requestOrigin } from "@/utils/request-origin";
|
||||||
|
|
||||||
// N10 (PLAN-API.md D2) — POST /api/external/documents
|
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
|
||||||
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
||||||
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
|
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
|
||||||
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
|
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
|
||||||
|
|
@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||||
|
|
||||||
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||||
|
|
||||||
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
|
// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม
|
||||||
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
|
// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16)
|
||||||
* 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) {
|
export async function POST(req: Request) {
|
||||||
const unauthorized = requireExternalApiKey(req);
|
const unauthorized = requireExternalApiKey(req);
|
||||||
44
app/api/swagger/route.ts
Normal file
44
app/api/swagger/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
109
app/api/template/download-docx/route.ts
Normal file
109
app/api/template/download-docx/route.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
|
||||||
|
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||||
|
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??"";
|
||||||
|
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??"";
|
||||||
|
const TMPL_PATH = process.env.TMPL_PATH??"";
|
||||||
|
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
|
||||||
|
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("download-docx env",DOCEDIT_BASE_URL,EXTERNAL_API_KEY,TMPL_PATH)
|
||||||
|
|
||||||
|
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
|
||||||
|
{
|
||||||
|
// console.log("processRequest get:",fid,tid,uid,uname)
|
||||||
|
// console.log(process.cwd())
|
||||||
|
const filePath = path.join(TMPL_PATH,`${fid}.json`);
|
||||||
|
try {
|
||||||
|
if (!fid||!tid||!uid||!uname) {
|
||||||
|
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
|
||||||
|
const rawJson = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const jsonData = JSON.parse(rawJson);
|
||||||
|
if (!jsonData || typeof jsonData !== 'object' ) {
|
||||||
|
return NextResponse.json({error:'Invalid data format'}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!!
|
||||||
|
|
||||||
|
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
|
||||||
|
if(!Object.hasOwn(jsonData, 'info'))
|
||||||
|
jsonData['info']={}
|
||||||
|
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname})
|
||||||
|
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
|
||||||
|
|
||||||
|
//ยิงไปหาระบบ Docedit เพื่อเอา Stream
|
||||||
|
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
|
||||||
|
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': EXTERNAL_API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(jsonData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// เช็ค Content-Type และ Status ก่อน
|
||||||
|
const contentType = docxResponse.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (!docxResponse.ok || contentType.includes('text/html')) {
|
||||||
|
const errorText = await docxResponse.text();
|
||||||
|
console.error('download-docx:Invalid docx Response:', errorText);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
|
||||||
|
const stream = docxResponse.body;
|
||||||
|
|
||||||
|
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
|
||||||
|
const download_file = `${fid}_${uid}_xxx`
|
||||||
|
return new NextResponse(stream as unknown as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
|
||||||
|
'Cache-Control': 'no-store, max-age=0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error:any) {
|
||||||
|
if ( error.code === 'ENOENT') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Data not found: ${filePath}`},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest)
|
||||||
|
{
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const fid = searchParams.get('fid') || '';
|
||||||
|
const tid = searchParams.get('tid') || '';
|
||||||
|
const uid = searchParams.get('uid') || '';
|
||||||
|
const uname = searchParams.get('uname') || '';
|
||||||
|
return await processRequest(fid,tid,uid,uname)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const fid = String(formData.get('fid'))
|
||||||
|
const tid = String(formData.get('tid'))
|
||||||
|
const uid = String(formData.get('uid'))
|
||||||
|
const uname = String(formData.get('uname'))
|
||||||
|
return await processRequest(fid,tid,uid,uname)
|
||||||
|
}
|
||||||
82
app/api/template/extract/route.ts
Normal file
82
app/api/template/extract/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
|
||||||
|
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
|
||||||
|
// export const runtime = "nodejs";
|
||||||
|
|
||||||
|
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) ||
|
||||||
|
(file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Field "file" must be a .docx file.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
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 = properties?.custom?.ticket_data
|
||||||
|
const HmacSha256 = String(properties?.custom?.HmacSha256)
|
||||||
|
const Ed25519 = String(properties?.custom?.Ed25519)
|
||||||
|
|
||||||
|
if(ticket_data && typeof ticket_data === 'string'){
|
||||||
|
const ticketDataObj= JSON.parse(ticket_data)
|
||||||
|
const unix_timestamp = Number(ticketDataObj.exp)*1000
|
||||||
|
if(unix_timestamp){
|
||||||
|
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
|
||||||
|
}
|
||||||
|
if(TEST_SECRET && HmacSha256){
|
||||||
|
verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
|
||||||
|
}
|
||||||
|
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
|
||||||
|
verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let returnData : Record<string, unknown> = {cc,bookmark,properties,verify}
|
||||||
|
return NextResponse.json(returnData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Internal Server Error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/api/template/generate/route.ts
Normal file
104
app/api/template/generate/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// สร้าง docx จาก json
|
||||||
|
// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้
|
||||||
|
// - สร้าง signature/hash จาก json.info.ticket_data
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import {readFile} from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||||
|
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
|
||||||
|
const TMPL_PATH = process.env.TMPL_PATH || ''
|
||||||
|
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
|
||||||
|
const SECRET = process.env.SECRET || ''
|
||||||
|
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||||
|
|
||||||
|
if (!EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
|
||||||
|
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
|
||||||
|
|
||||||
|
// console.log("generate env:",EXTERNAL_API_KEY,TMPL_PATH,PRIVATE_KEY_HEX,SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
|
||||||
|
const apiKey = request.headers.get('x-api-key');
|
||||||
|
|
||||||
|
if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized: Invalid API Key' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// รับข้อมูล JSON Payload
|
||||||
|
const templateData:DocumentTemplate = (await request.json());
|
||||||
|
// console.log(templateData)
|
||||||
|
// ต้องมี templateData.info.ticket_data เป็น json string เสมอ
|
||||||
|
if (!templateData || !templateData.info ||
|
||||||
|
!templateData.info.ticket_data ||
|
||||||
|
typeof templateData.info.ticket_data !== "string"
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "template/generate json.info.ticket_data not avaliable",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ticketObj = JSON.parse(templateData.info.ticket_data)
|
||||||
|
const tid = String(ticketObj.tid)
|
||||||
|
const fid = String(ticketObj.fid)
|
||||||
|
const uid = String(ticketObj.uid)
|
||||||
|
if(!tid||!fid||!uid)
|
||||||
|
{
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "template/generate require json.info.ticket_data.(fid|tid|uid)",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//เปิดไฟล์ template
|
||||||
|
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`);
|
||||||
|
let templateBuffer: Buffer = await readFile(tmplPath)
|
||||||
|
|
||||||
|
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);
|
||||||
|
|
||||||
|
// สร้าง ReadableStream ของไฟล์ .docx
|
||||||
|
// ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System
|
||||||
|
// const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const docxStream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(output);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word
|
||||||
|
const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`)
|
||||||
|
return new NextResponse(docxStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'Content-Disposition': `attachment; filename="${dlFileName}.docx"`,
|
||||||
|
'Cache-Control': 'no-store, max-age=0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error:any) {
|
||||||
|
if ( error.code === 'ENOENT') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound: Invalid template ID or incorrect storage path'},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Error generating docx:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to generate document payload'},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/api/template/render/route.ts
Normal file
135
app/api/template/render/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
||||||
|
const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||||
|
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||||
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
|
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const template = formData.get("template");
|
||||||
|
const dataFile = formData.get("data");
|
||||||
|
const dataJson = formData.get("dataJson");
|
||||||
|
const TEST_SECRET = String(formData.get("secret")||"")
|
||||||
|
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
|
||||||
|
|
||||||
|
// ตรวจสอบ Template
|
||||||
|
if (!(template instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Missing DOCX file in field "template".',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.type !== DOCX_CONTENT_TYPE &&
|
||||||
|
!template.name.toLowerCase().endsWith(".docx") &&
|
||||||
|
template.size > MAX_UPLOAD_BYTES
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Field "template" must be a .docx file and not 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(
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
error: "JSON data must be an object.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Template File → Buffer
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
let templateBuffer: Buffer = Buffer.from(
|
||||||
|
await template.arrayBuffer(),
|
||||||
|
);
|
||||||
|
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ส่ง DOCX กลับ
|
||||||
|
// -----------------------------
|
||||||
|
if(templateData?.info?.ticket_data)
|
||||||
|
return new NextResponse(output as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": DOCX_CONTENT_TYPE,
|
||||||
|
"Content-Disposition":
|
||||||
|
'attachment; filename="generated-document.docx"',
|
||||||
|
"Content-Length": String(output.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Internal Server Error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
10
app/api/template/test/route.ts
Normal file
10
app/api/template/test/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
service: "docx-template-api",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ export default async function AnnotatePage({
|
||||||
|
|
||||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||||
const user = getSessionUser();
|
const user = getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/note/login");
|
||||||
|
|
||||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||||
if (!file) notFound();
|
if (!file) notFound();
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
|
||||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||||
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
|
||||||
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||||
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||||
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||||
|
|
@ -134,7 +134,7 @@ export default async function SummaryPage({
|
||||||
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
|
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
|
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${id}`}
|
href={`/note/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}`}
|
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" />
|
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
|
||||||
|
|
@ -154,7 +154,7 @@ export default async function SummaryPage({
|
||||||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${id}`}
|
href={`/note/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}`}
|
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}`}
|
||||||
>
|
>
|
||||||
เปิดหน้าอ่าน
|
เปิดหน้าอ่าน
|
||||||
|
|
@ -7,9 +7,9 @@ import { firstSearchParam } from "@/utils/external-file";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/annotate?fileUrl=&fileId=&typeId=`
|
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
|
||||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/annotate/<id>` ของตัว viewer จริง
|
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
|
||||||
* (entry เดิมที่หน้า "/" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /?fileUrl= และ /annotate?fileUrl=)
|
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
|
||||||
*/
|
*/
|
||||||
export default async function AnnotateEntryPage({
|
export default async function AnnotateEntryPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|
@ -24,7 +24,7 @@ export default async function AnnotateEntryPage({
|
||||||
firstSearchParam(searchParams.typeId),
|
firstSearchParam(searchParams.typeId),
|
||||||
);
|
);
|
||||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||||
<div
|
<div
|
||||||
|
|
@ -34,7 +34,7 @@ export default async function AnnotateEntryPage({
|
||||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/note"
|
||||||
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"
|
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 อยู่ที่ /annotate/[id])
|
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
<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">
|
<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>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/note"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
กลับหน้ารายการ
|
กลับหน้ารายการ
|
||||||
|
|
@ -130,7 +130,7 @@ export default async function GroupPage({
|
||||||
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
// 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">
|
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/note"
|
||||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
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" />
|
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||||
|
|
@ -22,7 +22,7 @@ export default function LoginPage({
|
||||||
|
|
||||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||||
const user = getSessionUser();
|
const user = getSessionUser();
|
||||||
if (user) redirect(next ?? "/");
|
if (user) redirect(next ?? "/note");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||||
236
app/note/page.tsx
Normal file
236
app/note/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,13 @@ import {
|
||||||
} from "@/utils/file-kind";
|
} from "@/utils/file-kind";
|
||||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||||
|
|
||||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
|
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||||
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
|
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
|
||||||
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
|
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
|
||||||
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
|
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
|
||||||
// เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
|
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||||
|
|
||||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
||||||
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
||||||
245
app/page.tsx
245
app/page.tsx
|
|
@ -1,236 +1,33 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { asc } from "drizzle-orm";
|
|
||||||
import { FileImage, FileText, Folder } from "lucide-react";
|
|
||||||
|
|
||||||
import { db } from "@/db";
|
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
|
||||||
import { files } from "@/db/schema";
|
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
|
||||||
import AppNav from "@/components/AppNav";
|
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
|
||||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
|
||||||
import UploadFileForm from "@/components/UploadFileForm";
|
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
|
||||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
|
||||||
import { formatThaiDate } from "@/utils/date-format";
|
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
|
||||||
import { getSessionUser } from "@/lib/session";
|
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
|
||||||
import { registerExternalFile } from "@/lib/external-import";
|
|
||||||
import { firstSearchParam } from "@/utils/external-file";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function RootPage({
|
||||||
|
|
||||||
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,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Record<string, string | string[] | undefined>;
|
searchParams: Record<string, string | string[] | undefined>;
|
||||||
}) {
|
}) {
|
||||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
const entries = Object.entries(searchParams);
|
||||||
const user = getSessionUser();
|
const isEcourtLink = entries.some(([key]) =>
|
||||||
if (!user) redirect("/login");
|
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
|
||||||
|
|
||||||
// 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}`);
|
if (!isEcourtLink) {
|
||||||
registerError = result.error;
|
redirect("/wordaddin");
|
||||||
}
|
}
|
||||||
|
const qs = new URLSearchParams();
|
||||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
for (const [key, value] of entries) {
|
||||||
|
if (typeof value === "string") {
|
||||||
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
|
qs.set(key, value);
|
||||||
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
|
} else if (Array.isArray(value)) {
|
||||||
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
|
for (const v of value) qs.append(key, v);
|
||||||
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(
|
redirect(`/note?${qs.toString()}`);
|
||||||
(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'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
app/tmpl/page.tsx
Normal file
101
app/tmpl/page.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// app/page.tsx (ระบบ B)
|
||||||
|
'use client';
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
// import { createDownloadFormAction } from './actions';
|
||||||
|
import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const templateData: DropdownData = {
|
||||||
|
T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'],
|
||||||
|
T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'],
|
||||||
|
};
|
||||||
|
const handleTmplSelect = (template: string, data: string) => {
|
||||||
|
//console.log('Location Selected:', { template, data });
|
||||||
|
};
|
||||||
|
const handleInput = (e: ChangeEvent<HTMLInputElement>)=>{
|
||||||
|
// console.log(e.target)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
|
||||||
|
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
|
||||||
|
<div>
|
||||||
|
ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET)
|
||||||
|
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||||
|
ตัวอย่างหน้าแบบ <Link href='/tmpl/dl.html'>Plain HTML</Link>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||||
|
<form action="/docedit/api/template/download-docx" method="POST">
|
||||||
|
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
|
||||||
|
<DependentDropdown data={templateData} name1='tid' name2='fid'
|
||||||
|
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
|
||||||
|
<div className="mb-2"></div>
|
||||||
|
<div>
|
||||||
|
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#0070f3',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '5px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ดาวน์โหลดเอกสาร MS Word
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||||
|
<h2 className="font-semibold text-blue-600 mb-2">A Link Get(new)</h2>
|
||||||
|
<Link href="/api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</Link><br/>
|
||||||
|
<Link href="/api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</Link><br/>
|
||||||
|
<Link href="/api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</Link><br/>
|
||||||
|
|
||||||
|
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</Link><br/>
|
||||||
|
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</Link><br/>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||||
|
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
|
||||||
|
Template: <Link href="/tmpl/T_X.docx">T_X.docx</Link>,
|
||||||
|
<Link href="/tmpl/T12_X.docx">T12_X.docx</Link><br/>
|
||||||
|
Data: <Link href="tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</Link>,
|
||||||
|
<Link href="/tmpl/T_no_warning.json">T_no_warning.json</Link>,
|
||||||
|
<Link href="/tmpl/TS_P2_IN.json">TS_P2_IN.json</Link><br/>
|
||||||
|
<Link href="/tmpl/T12_b2_IN.json">T12_b2_IN.json</Link>,
|
||||||
|
<Link href="/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</Link>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||||
|
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
|
||||||
|
<div className="text-xl font-bold text-center">
|
||||||
|
หน้านี้(เลือกไฟล์)<br/>
|
||||||
|
↓ <br/>
|
||||||
|
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
|
||||||
|
↓ <br/>
|
||||||
|
generate(เปิดเทมเพลต,สร้าง docx)<br/>
|
||||||
|
↓ <br/>
|
||||||
|
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
|
||||||
|
↓ <br/>
|
||||||
|
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
|
||||||
|
↓ <br/>
|
||||||
|
Versioning(คุมเวอร์ชั่น)<br/>
|
||||||
|
↓ <br/>
|
||||||
|
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/wordaddin/error.tsx
Normal file
37
app/wordaddin/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า
|
||||||
|
// "Application error: a client-side exception has occurred" ของ Next
|
||||||
|
// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร
|
||||||
|
// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้)
|
||||||
|
export default function WordAddinError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col gap-3 p-4">
|
||||||
|
<h2 className="font-display text-base font-semibold text-ink">task pane ทำงานผิดพลาด</h2>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-canvas p-3 text-xs text-error-ink"
|
||||||
|
aria-label="รายละเอียดข้อผิดพลาด"
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
{error.digest ? `\n[digest ${error.digest}]` : ""}
|
||||||
|
{error.stack ? `\n\n${error.stack}` : ""}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring w-fit rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-primary hover:text-primary"
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/wordaddin/layout.tsx
Normal file
10
app/wordaddin/layout.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "e-Court Documents Versioning",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
33
app/wordaddin/manifest.xml/route.ts
Normal file
33
app/wordaddin/manifest.xml/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { requestOrigin } from "@/utils/request-origin";
|
||||||
|
|
||||||
|
// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload
|
||||||
|
//
|
||||||
|
// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม
|
||||||
|
// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้
|
||||||
|
// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route
|
||||||
|
// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว
|
||||||
|
// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template
|
||||||
|
// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น)
|
||||||
|
//
|
||||||
|
// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker
|
||||||
|
// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image)
|
||||||
|
|
||||||
|
/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */
|
||||||
|
const DEV_ORIGIN = "https://localhost:3000";
|
||||||
|
|
||||||
|
const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml");
|
||||||
|
|
||||||
|
export function GET(req: Request): Response {
|
||||||
|
const template = fs.readFileSync(TEMPLATE, "utf8");
|
||||||
|
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
|
||||||
|
return new Response(manifest, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/xml; charset=utf-8",
|
||||||
|
// origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
7
app/wordaddin/page.tsx
Normal file
7
app/wordaddin/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
|
||||||
|
|
||||||
|
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
|
||||||
|
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
|
||||||
|
export default function WordAddinPage() {
|
||||||
|
return <WordAddinGate />;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
|
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
|
||||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||||
// เป็นแถบของตัวเองกลางหน้า
|
// เป็นแถบของตัวเองกลางหน้า
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -15,7 +15,7 @@ export default function AppNav({ userName }: { userName: string }) {
|
||||||
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
|
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
|
||||||
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/note"
|
||||||
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
|
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">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useEffect } from "react";
|
||||||
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
|
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
|
||||||
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
|
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
|
||||||
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
|
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
|
||||||
* app/annotate/[id]/page.tsx */
|
* app/note/annotate/[id]/page.tsx */
|
||||||
export default function DebugConsole() {
|
export default function DebugConsole() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default function EcourtLinkForm() {
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
||||||
router.push(`/annotate?${queryString}`);
|
router.push(`/note/annotate?${queryString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
|
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
|
||||||
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
|
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
|
||||||
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
|
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
|
||||||
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
|
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
|
||||||
|
|
@ -36,7 +36,7 @@ export default function LoginForm({ next }: { next: string | null }) {
|
||||||
try {
|
try {
|
||||||
const result = await login(usernameInput.value, passwordInput.value);
|
const result = await login(usernameInput.value, passwordInput.value);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
router.replace(next ?? "/");
|
router.replace(next ?? "/note");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
|
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action
|
||||||
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
|
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
|
||||||
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
|
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ function RowNote({ text }: { text: string }) {
|
||||||
function OpenFileButton({ file }: { file: GroupFileSummary }) {
|
function OpenFileButton({ file }: { file: GroupFileSummary }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${file.id}`}
|
href={`/note/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"
|
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 ? (
|
{file.isImage ? (
|
||||||
|
|
@ -178,8 +178,8 @@ export function GroupList({
|
||||||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||||
const href = isBm
|
const href = isBm
|
||||||
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||||
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
: `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||||
return (
|
return (
|
||||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||||
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
|
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
|
||||||
|
|
|
||||||
|
|
@ -311,13 +311,29 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||||||
const r = probe.getClientRects()[0];
|
const r = probe.getClientRects()[0];
|
||||||
let hit = -1;
|
let hit = -1;
|
||||||
if (r !== undefined) {
|
if (r !== undefined) {
|
||||||
hit = kept.findIndex(
|
// สองเงื่อนไขในการนับตัวอักษรเข้ากลุ่ม rect ของบรรทัด: (1) จุดกึ่งกลาง
|
||||||
(k) =>
|
// แนวตั้งของตัวอักษรต้องอยู่ในแถบของ rect (2) เลือก rect ที่ซ้อนทับแนวตั้ง
|
||||||
r.left < k.right + eps &&
|
// มากที่สุด ไม่ใช่ตัวแรกที่เจอ — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง
|
||||||
r.right > k.left - eps &&
|
// ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรของบรรทัดถัดไป (โดยเฉพาะตัวที่มีสระ
|
||||||
r.top < k.bottom + eps &&
|
// บนดันกรอบขึ้น) เหลื่อมกับ rect ของบรรทัดก่อนหน้าจริง ๆ 1-3px การเช็คแค่
|
||||||
r.bottom > k.top - eps,
|
// การเหลื่อมจึงไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย
|
||||||
);
|
// (ลากจบท้ายบรรทัดแล้ว caret ปลายทางหลุดเข้า span ถัดไป) เดินถึงตัวอักษร
|
||||||
|
// ของบรรทัดนั้นแล้วดูดเข้ากลุ่มเดียวกัน โดนเรียงรวมด้วย x จนสองบรรทัดสอด
|
||||||
|
// แทรกตัวต่อตัวเป็น mojibake — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปยังอยู่นอก
|
||||||
|
// แถบเสมอ (เหลื่อมแค่ขอบ) เป็นเกณฑ์แยกบรรทัดที่เด็ดกว่า (bug report 2026-10-05)
|
||||||
|
const cy = r.top + r.height / 2;
|
||||||
|
let best = 0;
|
||||||
|
for (let ki = 0; ki < kept.length; ki++) {
|
||||||
|
const k = kept[ki];
|
||||||
|
if (r.left >= k.right + eps || r.right <= k.left - eps) continue;
|
||||||
|
if (cy < k.top - eps || cy > k.bottom + eps) continue;
|
||||||
|
const score =
|
||||||
|
Math.min(r.bottom, k.bottom + eps) - Math.max(r.top, k.top - eps);
|
||||||
|
if (score > best) {
|
||||||
|
best = score;
|
||||||
|
hit = ki;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
|
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
|
||||||
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
|
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
|
||||||
|
|
@ -343,9 +359,13 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||||||
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
|
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
|
||||||
const ra = kept[a];
|
const ra = kept[a];
|
||||||
const rb = kept[b];
|
const rb = kept[b];
|
||||||
if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) {
|
// ใช้เกณฑ์เดียวกับ sameLine ด้านล่าง: rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด
|
||||||
return midY(ra) - midY(rb);
|
// เรียงบน→ล่าง, ซ้อนกัน = บรรทัดเดียวกันเรียงซ้าย→ขวา — เดิมเทียบ |midY| กับ
|
||||||
}
|
// ความสูงซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px สูง 16px = "บรรทัดเดียวกัน")
|
||||||
|
// จนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่าบรรทัดถัดไป) โดนเรียงตาม left
|
||||||
|
// กลางเป็นข้อความสลับลำดับบรรทัด (code review 2026-10-05)
|
||||||
|
const vOv = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||||||
|
if (vOv <= eps) return midY(ra) - midY(rb);
|
||||||
return ra.left - rb.left;
|
return ra.left - rb.left;
|
||||||
});
|
});
|
||||||
let out = "";
|
let out = "";
|
||||||
|
|
@ -354,7 +374,11 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||||||
if (prev >= 0) {
|
if (prev >= 0) {
|
||||||
const a = kept[prev];
|
const a = kept[prev];
|
||||||
const b = kept[ri];
|
const b = kept[ri];
|
||||||
const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height);
|
// คนละบรรทัด = rect ไม่ซ้อนทับแนวตั้งกัน (แค่ชิด/แตะกัน) — เดิมใช้เทียบ midY
|
||||||
|
// กับความสูง ซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px, สูง 16px: 16 ≤ 16
|
||||||
|
// = "บรรทัดเดียวกัน") จนไม่มี \n คั่น (และช่วยให้โดนจัดกลุ่มรวมกันในตอน hit)
|
||||||
|
const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||||
|
const sameLine = vOverlap > eps;
|
||||||
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
|
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
|
||||||
}
|
}
|
||||||
out += entries
|
out += entries
|
||||||
|
|
@ -1640,7 +1664,7 @@ export default function PDFViewer({
|
||||||
: userFilter;
|
: userFilter;
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
withBasePath(
|
withBasePath(
|
||||||
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
|
`/api/note/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1698,10 +1722,10 @@ export default function PDFViewer({
|
||||||
closeToolMenus();
|
closeToolMenus();
|
||||||
const objectUrls: string[] = [];
|
const objectUrls: string[] = [];
|
||||||
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
||||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
|
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/note/pdf ของเราแทนทางตรง — กัน CORS
|
||||||
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
||||||
const fileUrl = isHttpUrl(file.pdfUrl)
|
const fileUrl = isHttpUrl(file.pdfUrl)
|
||||||
? withBasePath(`/api/pdf?fileId=${file.id}`)
|
? withBasePath(`/api/note/pdf?fileId=${file.id}`)
|
||||||
: withBasePath(file.pdfUrl);
|
: withBasePath(file.pdfUrl);
|
||||||
(async () => {
|
(async () => {
|
||||||
let pages: { src: string | null; width: number; height: number }[];
|
let pages: { src: string | null; width: number; height: number }[];
|
||||||
|
|
@ -1743,11 +1767,11 @@ export default function PDFViewer({
|
||||||
(async () => {
|
(async () => {
|
||||||
const pdfjs = await import("pdfjs-dist");
|
const pdfjs = await import("pdfjs-dist");
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
|
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
|
||||||
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
// โหลดผ่าน /api/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||||||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||||||
let data: ArrayBuffer;
|
let data: ArrayBuffer;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`));
|
const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`));
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
data = await res.arrayBuffer();
|
data = await res.arrayBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -3373,7 +3397,7 @@ export default function PDFViewer({
|
||||||
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น
|
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น
|
||||||
(จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */}
|
(จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */}
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${file.id}/summary`}
|
href={`/note/annotate/${file.id}/summary`}
|
||||||
title="หน้าสรุปหมายเหตุ"
|
title="หน้าสรุปหมายเหตุ"
|
||||||
aria-label="หน้าสรุปหมายเหตุ"
|
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}`}
|
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}`}
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ function PageSections({
|
||||||
{pageSummaryLine(pageItems)}
|
{pageSummaryLine(pageItems)}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${fileId}?page=${pi + 1}`}
|
href={`/note/annotate/${fileId}?page=${pi + 1}`}
|
||||||
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
|
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}`
|
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||||
const href = isBm
|
const href = isBm
|
||||||
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
|
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||||
: `/annotate/${fileId}?ann=${item.dto.id}`;
|
: `/note/annotate/${fileId}?ann=${item.dto.id}`;
|
||||||
return (
|
return (
|
||||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||||
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
|
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
|
||||||
|
|
|
||||||
91
components/utils/DependentDropdown.tsx
Normal file
91
components/utils/DependentDropdown.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, ChangeEvent, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface DropdownData {
|
||||||
|
[key: string]: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DependentDropdownProps {
|
||||||
|
data: DropdownData;
|
||||||
|
name1: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||||
|
name2: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||||
|
label1?: string;
|
||||||
|
label2?: string;
|
||||||
|
placeholder1?: string;
|
||||||
|
placeholder2?: string;
|
||||||
|
onSelectChange?: (mainValue: string, subValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DependentDropdown({
|
||||||
|
data,
|
||||||
|
name1,
|
||||||
|
name2,
|
||||||
|
label1 = 'เลือกตัวเลือกหลัก:',
|
||||||
|
label2 = 'เลือกตัวเลือกย่อย:',
|
||||||
|
placeholder1 = '-- กรุณาเลือก --',
|
||||||
|
placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --',
|
||||||
|
onSelectChange,
|
||||||
|
}: DependentDropdownProps) {
|
||||||
|
const [mainSelection, setMainSelection] = useState<string>('');
|
||||||
|
const [subSelection, setSubSelection] = useState<string>('');
|
||||||
|
|
||||||
|
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setMainSelection(value);
|
||||||
|
setSubSelection('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSubSelection(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSelectChange) {
|
||||||
|
onSelectChange(mainSelection, subSelection);
|
||||||
|
}
|
||||||
|
}, [mainSelection, subSelection, onSelectChange]);
|
||||||
|
|
||||||
|
const subOptions = mainSelection ? data[mainSelection] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
{/* Dropdown ที่ 1 */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">{label1}</label>
|
||||||
|
<select
|
||||||
|
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||||
|
value={mainSelection}
|
||||||
|
onChange={handleMainChange}
|
||||||
|
required
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{placeholder1}</option>
|
||||||
|
{Object.keys(data).map((key) => (
|
||||||
|
<option key={key} value={key}>{key}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown ที่ 2 */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">{label2}</label>
|
||||||
|
<select
|
||||||
|
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||||
|
value={subSelection}
|
||||||
|
onChange={handleSubChange}
|
||||||
|
disabled={!mainSelection}
|
||||||
|
required
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
|
||||||
|
</option>
|
||||||
|
{subOptions.map((item) => (
|
||||||
|
<option key={item} value={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
|
||||||
|
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
|
||||||
|
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
|
||||||
|
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
|
||||||
|
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
|
||||||
|
* (pattern overlay เดียวกับ AnnotationSidebar)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = "สร้าง version ใหม่?";
|
||||||
|
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
|
||||||
|
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
|
||||||
|
|
||||||
|
const BTN_SECONDARY =
|
||||||
|
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
const BTN_PRIMARY =
|
||||||
|
"focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
export function ConfirmVersionDialog({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onConfirmClose,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
|
||||||
|
busy: boolean;
|
||||||
|
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
|
||||||
|
title?: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
|
||||||
|
onConfirm: (message: string) => void;
|
||||||
|
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
|
||||||
|
onConfirmClose?: (message: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
|
||||||
|
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMessage("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
|
||||||
|
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !busy) onDismiss();
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
|
||||||
|
);
|
||||||
|
const first = focusables?.item(0);
|
||||||
|
const last = focusables?.item((focusables?.length ?? 0) - 1);
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, busy, onDismiss]);
|
||||||
|
|
||||||
|
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
return () => {
|
||||||
|
previous?.focus();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||||
|
onClick={() => !busy && onDismiss()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title ?? DEFAULT_TITLE}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
|
||||||
|
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
|
||||||
|
คำอธิบาย version (ไม่บังคับ)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="wordaddin-version-message"
|
||||||
|
className="genesis-input mt-1 block w-full resize-y"
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
|
||||||
|
maxLength={2048}
|
||||||
|
rows={3}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="คำอธิบาย version"
|
||||||
|
/>
|
||||||
|
{busy ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Spinner label="กำลังดำเนินการ…" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
|
||||||
|
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onConfirm(message)}
|
||||||
|
>
|
||||||
|
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{onConfirmClose ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} w-full`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onConfirmClose(message)}
|
||||||
|
>
|
||||||
|
บันทึกเวอร์ชันและปิดเอกสาร
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmVersionDialog;
|
||||||
45
components/wordaddin/MessageBar.tsx
Normal file
45
components/wordaddin/MessageBar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
|
||||||
|
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
|
||||||
|
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
|
||||||
|
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
|
||||||
|
* แถบกว้างเท่ากันทั้งคอลัมน์
|
||||||
|
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
error: CircleAlert,
|
||||||
|
warning: TriangleAlert,
|
||||||
|
success: CircleCheck,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function MessageBar({
|
||||||
|
intent,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
intent: "error" | "warning" | "success";
|
||||||
|
children: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const Icon = ICONS[intent];
|
||||||
|
const tone =
|
||||||
|
intent === "error"
|
||||||
|
? "border-error/30 bg-error/10 text-error-ink"
|
||||||
|
: intent === "warning"
|
||||||
|
? "border-warning/30 bg-warning/15 text-warning-ink"
|
||||||
|
: "border-success/30 bg-success/10 text-success-ink";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={intent === "error" ? "alert" : "status"}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1 break-words">{children}</div>
|
||||||
|
{action ? <div className="shrink-0">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
components/wordaddin/SaveControls.tsx
Normal file
65
components/wordaddin/SaveControls.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { CircleCheck, Save, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||||
|
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
|
||||||
|
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||||
|
*/
|
||||||
|
export function SaveControls({
|
||||||
|
disabled,
|
||||||
|
busy,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
closeSupported,
|
||||||
|
onSave,
|
||||||
|
onSaveAndClose,
|
||||||
|
}: {
|
||||||
|
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
|
||||||
|
disabled: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
status: "saving" | "saved" | null;
|
||||||
|
error: string | null;
|
||||||
|
closeSupported: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onSaveAndClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
<Save className="size-4" aria-hidden />
|
||||||
|
บันทึก
|
||||||
|
</button>
|
||||||
|
{closeSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={onSaveAndClose}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden />
|
||||||
|
บันทึกและปิดเอกสาร
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
|
||||||
|
{!busy && status === "saved" ? (
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
|
||||||
|
<CircleCheck className="size-4 text-success-ink" aria-hidden />
|
||||||
|
บันทึกแล้ว
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SaveControls;
|
||||||
13
components/wordaddin/Spinner.tsx
Normal file
13
components/wordaddin/Spinner.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
|
||||||
|
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
|
||||||
|
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
616
components/wordaddin/TaskpaneApp.tsx
Normal file
616
components/wordaddin/TaskpaneApp.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
/* global Office */
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { FileUp, GitCommitHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
|
||||||
|
import * as authService from "@/wordaddin/services/authService";
|
||||||
|
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
|
||||||
|
import * as documentService from "@/wordaddin/services/documentService";
|
||||||
|
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||||
|
import * as metadataService from "@/wordaddin/services/metadataService";
|
||||||
|
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
|
||||||
|
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
|
||||||
|
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||||
|
|
||||||
|
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import SaveControls from "./SaveControls";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import TemplateToolsPanel from "./TemplateToolsPanel";
|
||||||
|
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task pane หลัก — state machine:
|
||||||
|
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
|
||||||
|
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
|
||||||
|
* → (ticket ผ่าน + tracked document) tracking
|
||||||
|
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
|
||||||
|
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
|
||||||
|
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
|
||||||
|
* ตาม fid — PLAN-ADDIN-AUTH.md)
|
||||||
|
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
|
||||||
|
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
|
||||||
|
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
||||||
|
*
|
||||||
|
* กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||||
|
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||||
|
*
|
||||||
|
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
|
||||||
|
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Phase = "checking" | "no-access" | "untracked" | "tracking";
|
||||||
|
|
||||||
|
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
|
||||||
|
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
|
||||||
|
type Tab = "versioning" | "template";
|
||||||
|
|
||||||
|
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
|
||||||
|
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
|
||||||
|
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
|
||||||
|
const tabButtonClass = (active: boolean) =>
|
||||||
|
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
|
||||||
|
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const BTN_PRIMARY =
|
||||||
|
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
|
||||||
|
function demoVersions(now: number): VersionRecord[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
versionId: "demo-v3",
|
||||||
|
versionNumber: 3,
|
||||||
|
createdAt: new Date(now - 3_600_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: "ปรับคำร้องตามคำแนะนำอัยการ",
|
||||||
|
hasPdf: true,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
versionId: "demo-v2",
|
||||||
|
versionNumber: 2,
|
||||||
|
createdAt: new Date(now - 86_400_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: null,
|
||||||
|
hasPdf: true,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
versionId: "demo-v1",
|
||||||
|
versionNumber: 1,
|
||||||
|
createdAt: new Date(now - 172_800_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: "สร้างเอกสารครั้งแรก",
|
||||||
|
hasPdf: false,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
|
||||||
|
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("versioning");
|
||||||
|
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
|
||||||
|
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
|
||||||
|
const [templateBusy, setTemplateBusy] = useState(false);
|
||||||
|
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
|
||||||
|
|
||||||
|
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
|
||||||
|
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||||
|
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
|
||||||
|
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
|
||||||
|
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
|
||||||
|
const [flowError, setFlowError] = useState<string | null>(null);
|
||||||
|
const [flowWarning, setFlowWarning] = useState<string | null>(null);
|
||||||
|
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
|
||||||
|
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
|
||||||
|
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
|
||||||
|
const [closeSupported] = useState(() => documentService.canCloseDocument());
|
||||||
|
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
|
||||||
|
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||||
|
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
|
||||||
|
const [hasUncommitted, setHasUncommitted] = useState(false);
|
||||||
|
|
||||||
|
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
|
||||||
|
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
|
||||||
|
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
||||||
|
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
||||||
|
if (error instanceof AuthRequiredError) {
|
||||||
|
setPhase("no-access");
|
||||||
|
setActiveTab("versioning");
|
||||||
|
setDocumentId(null);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setVersions([]);
|
||||||
|
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
setHistoryError(null);
|
||||||
|
try {
|
||||||
|
setVersions(await getVersions(id));
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleAuthRequired],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
|
||||||
|
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||||
|
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
||||||
|
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
|
||||||
|
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
|
||||||
|
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
||||||
|
const detect = useCallback(async () => {
|
||||||
|
setPhase("checking");
|
||||||
|
setBootError(null);
|
||||||
|
try {
|
||||||
|
let properties: metadataService.TrackedDocumentProperties | null = null;
|
||||||
|
try {
|
||||||
|
properties = await metadataService.readTrackedProperties();
|
||||||
|
} catch {
|
||||||
|
properties = null;
|
||||||
|
}
|
||||||
|
if (!properties) {
|
||||||
|
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
|
||||||
|
setPhase("no-access");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signIn = await authService.signInWithTicket({
|
||||||
|
ticketData: properties.ticketData,
|
||||||
|
ticketHmac: properties.ticketHmac,
|
||||||
|
});
|
||||||
|
if (!signIn.ok) {
|
||||||
|
setNoAccessNotice(signIn.message);
|
||||||
|
setPhase("no-access");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsername(authService.getUsername() ?? null);
|
||||||
|
|
||||||
|
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||||
|
const id = tracked ? properties.documentTrackingId : undefined;
|
||||||
|
if (!id) {
|
||||||
|
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
|
||||||
|
setDocumentTemplateId(properties.templateId ?? null);
|
||||||
|
setPhase("untracked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocumentId(id);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setPhase("tracking");
|
||||||
|
await refreshHistory(id);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
|
||||||
|
void detect();
|
||||||
|
}, [demo, detect]);
|
||||||
|
|
||||||
|
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
|
||||||
|
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
|
||||||
|
useEffect(() => {
|
||||||
|
autoSaveService.onError = (error) =>
|
||||||
|
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
|
||||||
|
autoSaveService.onSaved = () => setAutoSaveError(null);
|
||||||
|
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
|
||||||
|
if (!demo) {
|
||||||
|
autoSaveService.start();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
autoSaveService.stop();
|
||||||
|
autoSaveService.onError = undefined;
|
||||||
|
autoSaveService.onSaved = undefined;
|
||||||
|
autoSaveService.onStateChange = undefined;
|
||||||
|
};
|
||||||
|
}, [demo]);
|
||||||
|
|
||||||
|
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
|
||||||
|
const handleSaveOnly = async () => {
|
||||||
|
setSaveBusy(true);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError(null);
|
||||||
|
setAutoSaveError(null);
|
||||||
|
try {
|
||||||
|
await autoSaveService.save();
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus(null);
|
||||||
|
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
|
||||||
|
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
|
||||||
|
const handleSaveAndClose = async () => {
|
||||||
|
setSaveBusy(true);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError(null);
|
||||||
|
setAutoSaveError(null);
|
||||||
|
try {
|
||||||
|
await autoSaveService.suspend();
|
||||||
|
try {
|
||||||
|
await autoSaveService.forceSave();
|
||||||
|
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
|
||||||
|
} finally {
|
||||||
|
autoSaveService.resume();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus(null);
|
||||||
|
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
|
||||||
|
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
|
||||||
|
const handleStartVersioning = async (message: string) => {
|
||||||
|
setStarting(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
setFlowWarning(null);
|
||||||
|
try {
|
||||||
|
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
|
||||||
|
setDocumentId(result.documentId);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setPhase("tracking");
|
||||||
|
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||||
|
if (!result.hasPdf)
|
||||||
|
setFlowWarning(
|
||||||
|
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||||
|
);
|
||||||
|
await refreshHistory(result.documentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setProgressMessage(null);
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
|
||||||
|
* message คือ commit message จาก dialog (FR-13)
|
||||||
|
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
|
||||||
|
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
setFlowWarning(null);
|
||||||
|
try {
|
||||||
|
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||||
|
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
|
||||||
|
if (closeAfter) {
|
||||||
|
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||||
|
if (!result.hasPdf)
|
||||||
|
setFlowWarning(
|
||||||
|
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||||
|
);
|
||||||
|
await refreshHistory(documentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof VersionSavedButCloseFailedError) {
|
||||||
|
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
|
||||||
|
setFlowWarning(error.message);
|
||||||
|
await refreshHistory(documentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setProgressMessage(null);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||||
|
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||||
|
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
|
||||||
|
*/
|
||||||
|
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
|
||||||
|
setDownloading(version.versionId);
|
||||||
|
setFlowError(null);
|
||||||
|
try {
|
||||||
|
const link = await getDownloadLink(documentId, version.versionId, format);
|
||||||
|
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||||
|
Office.context.ui.openBrowserWindow(link.url);
|
||||||
|
setFlowSuccess(
|
||||||
|
format === "pdf"
|
||||||
|
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
|
||||||
|
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
|
||||||
|
{phase !== "no-access" && phase !== "checking" && username ? (
|
||||||
|
/* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
|
||||||
|
จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
|
||||||
|
<span
|
||||||
|
className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
|
||||||
|
title={username}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
|
||||||
|
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
|
||||||
|
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
|
||||||
|
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
|
||||||
|
<MessageBar intent="warning">{autoSaveError}</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ)
|
||||||
|
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
|
||||||
|
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
|
||||||
|
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
|
||||||
|
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
|
||||||
|
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
|
||||||
|
{(phase === "untracked" || phase === "tracking") && (
|
||||||
|
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={activeTab === "versioning"}
|
||||||
|
className={tabButtonClass(activeTab === "versioning")}
|
||||||
|
disabled={saving || starting || saveBusy || templateBusy}
|
||||||
|
onClick={() => setActiveTab("versioning")}
|
||||||
|
>
|
||||||
|
Versioning
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={activeTab === "template"}
|
||||||
|
className={tabButtonClass(activeTab === "template")}
|
||||||
|
disabled={saving || starting || saveBusy || templateBusy}
|
||||||
|
onClick={() => setActiveTab("template")}
|
||||||
|
>
|
||||||
|
เครื่องมือ Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "checking" ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 pt-10">
|
||||||
|
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||||
|
{bootError ? (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={() => void detect()}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bootError}
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "no-access" ? (
|
||||||
|
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "untracked" && activeTab === "versioning" ? (
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||||
|
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||||
|
</p>
|
||||||
|
{documentTemplateId ? (
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||||
|
และ sync ค่า fields ให้อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||||
|
disabled={starting}
|
||||||
|
onClick={() => setStartConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<FileUp className="size-4" aria-hidden />
|
||||||
|
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||||
|
</button>
|
||||||
|
<SaveControls
|
||||||
|
disabled={starting}
|
||||||
|
busy={saveBusy}
|
||||||
|
status={saveStatus}
|
||||||
|
error={saveError}
|
||||||
|
closeSupported={closeSupported}
|
||||||
|
onSave={() => void handleSaveOnly()}
|
||||||
|
onSaveAndClose={() => void handleSaveAndClose()}
|
||||||
|
/>
|
||||||
|
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||||
|
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "tracking" && activeTab === "versioning" ? (
|
||||||
|
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
{/* กลุ่มข้อความสถานะ */}
|
||||||
|
{hasUncommitted ? (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
|
||||||
|
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
|
||||||
|
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||||
|
|
||||||
|
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
|
||||||
|
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
|
||||||
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="size-4" aria-hidden />
|
||||||
|
Save & Version
|
||||||
|
</button>
|
||||||
|
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||||
|
<SaveControls
|
||||||
|
disabled={saving}
|
||||||
|
busy={saveBusy}
|
||||||
|
status={saveStatus}
|
||||||
|
error={saveError}
|
||||||
|
closeSupported={closeSupported}
|
||||||
|
onSave={() => void handleSaveOnly()}
|
||||||
|
onSaveAndClose={() => void handleSaveAndClose()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
|
||||||
|
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
|
||||||
|
|
||||||
|
<VersionHistoryPanel
|
||||||
|
versions={versions}
|
||||||
|
loading={historyLoading}
|
||||||
|
error={historyError}
|
||||||
|
downloadingVersionId={downloadingVersionId}
|
||||||
|
downloadingPdfVersionId={downloadingPdfVersionId}
|
||||||
|
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
|
||||||
|
onDownload={(version) => void handleDownload(version)}
|
||||||
|
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
|
||||||
|
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
|
||||||
|
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
|
||||||
|
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
|
||||||
|
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
|
||||||
|
เปิดค้างขณะสลับแท็บ) */}
|
||||||
|
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
|
||||||
|
<>
|
||||||
|
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
|
||||||
|
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
|
||||||
|
<ConfirmVersionDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
busy={saving}
|
||||||
|
onConfirm={(message) => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
void handleSaveAndVersion(message);
|
||||||
|
}}
|
||||||
|
{...(closeSupported
|
||||||
|
? {
|
||||||
|
onConfirmClose: (message: string) => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
void handleSaveAndVersion(message, true);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
onDismiss={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
|
||||||
|
<ConfirmVersionDialog
|
||||||
|
open={startConfirmOpen}
|
||||||
|
busy={starting}
|
||||||
|
title="เริ่มติดตามเอกสารนี้?"
|
||||||
|
description={
|
||||||
|
documentTemplateId
|
||||||
|
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
|
||||||
|
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
||||||
|
}
|
||||||
|
confirmLabel="เริ่มติดตาม (version 1)"
|
||||||
|
onConfirm={(message) => {
|
||||||
|
setStartConfirmOpen(false);
|
||||||
|
void handleStartVersioning(message);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setStartConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskpaneApp;
|
||||||
411
components/wordaddin/TemplateToolsPanel.tsx
Normal file
411
components/wordaddin/TemplateToolsPanel.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyTestValues,
|
||||||
|
insertBreakAtSelection,
|
||||||
|
insertParagraphAtSelection,
|
||||||
|
listContentControls,
|
||||||
|
selectContentControl,
|
||||||
|
supportsAllContentControlTypes,
|
||||||
|
type ScannedField,
|
||||||
|
} from "@/wordaddin/services/templateService";
|
||||||
|
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template
|
||||||
|
* สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking:
|
||||||
|
* 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง
|
||||||
|
* 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที
|
||||||
|
* โดยไม่ต้อง render ผ่าน /api/template/render)
|
||||||
|
* 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง
|
||||||
|
*
|
||||||
|
* state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง
|
||||||
|
* ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BTN_PRIMARY =
|
||||||
|
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
const BTN_SECONDARY =
|
||||||
|
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */
|
||||||
|
const BTN_ACTION =
|
||||||
|
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
/** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */
|
||||||
|
const BTN_INSERT =
|
||||||
|
"focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) {
|
||||||
|
/** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */
|
||||||
|
const [fields, setFields] = useState<ScannedField[] | null>(null);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [insertingParagraph, setInsertingParagraph] = useState(false);
|
||||||
|
const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null);
|
||||||
|
/** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
|
||||||
|
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
|
||||||
|
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const busy = scanning || applying || insertingParagraph || insertingBreak !== null;
|
||||||
|
|
||||||
|
/** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ
|
||||||
|
* Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount
|
||||||
|
* แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */
|
||||||
|
useEffect(() => {
|
||||||
|
onBusyChange?.(busy);
|
||||||
|
return () => onBusyChange?.(false);
|
||||||
|
}, [busy, onBusyChange]);
|
||||||
|
|
||||||
|
/** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */
|
||||||
|
const uniqueTags = useMemo(() => {
|
||||||
|
if (!fields) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tags: string[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
const tag = field.tag.trim();
|
||||||
|
if (tag === "" || seen.has(tag)) continue;
|
||||||
|
seen.add(tag);
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
const emptyTagCount = useMemo(
|
||||||
|
() => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0),
|
||||||
|
[fields],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */
|
||||||
|
const duplicateTags = useMemo(() => {
|
||||||
|
if (!fields) return [];
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const field of fields) {
|
||||||
|
const tag = field.tag.trim();
|
||||||
|
if (tag === "") continue;
|
||||||
|
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([tag]) => tag);
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
/** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย
|
||||||
|
* แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */
|
||||||
|
const hasFilledValue = useMemo(
|
||||||
|
() => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""),
|
||||||
|
[uniqueTags, values],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สแกนเอกสาร + เติมค่าฟอร์ม
|
||||||
|
* - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag
|
||||||
|
* พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว)
|
||||||
|
* - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม
|
||||||
|
* และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ
|
||||||
|
* **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่
|
||||||
|
* (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน)
|
||||||
|
*/
|
||||||
|
const runScan = async (merge: boolean) => {
|
||||||
|
setScanning(true);
|
||||||
|
setError(null);
|
||||||
|
if (!merge) {
|
||||||
|
setSuccess(null);
|
||||||
|
setWarning(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const scanned = await listContentControls();
|
||||||
|
setFields(scanned);
|
||||||
|
// Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล
|
||||||
|
// ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน
|
||||||
|
setCapabilityWarning(
|
||||||
|
supportsAllContentControlTypes()
|
||||||
|
? null
|
||||||
|
: "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้",
|
||||||
|
);
|
||||||
|
setValues((previous) => {
|
||||||
|
// สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่
|
||||||
|
// (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป)
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const field of scanned) {
|
||||||
|
const tag = field.tag.trim();
|
||||||
|
if (tag === "" || seen.has(tag)) continue;
|
||||||
|
seen.add(tag);
|
||||||
|
next[tag] = merge && tag in previous ? previous[tag] : field.text;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (scanError) {
|
||||||
|
setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ"));
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = async (id: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await selectContentControl(id);
|
||||||
|
} catch (viewError) {
|
||||||
|
setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */
|
||||||
|
const handleInsertParagraph = async () => {
|
||||||
|
setInsertingParagraph(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await insertParagraphAtSelection();
|
||||||
|
} catch (insertError) {
|
||||||
|
setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ"));
|
||||||
|
} finally {
|
||||||
|
setInsertingParagraph(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertBreak = async (breakKind: "line" | "page") => {
|
||||||
|
setInsertingBreak(breakKind);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await insertBreakAtSelection(breakKind);
|
||||||
|
} catch (insertError) {
|
||||||
|
setError(
|
||||||
|
getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setInsertingBreak(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setApplying(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setWarning(null);
|
||||||
|
try {
|
||||||
|
const result = await applyTestValues(values);
|
||||||
|
if (result.appliedCount === 0 && result.failures.length === 0) {
|
||||||
|
setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่");
|
||||||
|
} else {
|
||||||
|
if (result.appliedCount > 0) {
|
||||||
|
setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`);
|
||||||
|
}
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (result.missingTags.length > 0) {
|
||||||
|
problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (result.failures.length > 0) {
|
||||||
|
const first = result.failures[0];
|
||||||
|
problems.push(
|
||||||
|
`เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures
|
||||||
|
.map((failure) => failure.tag)
|
||||||
|
.join(", ")}) — ${first.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
setWarning(
|
||||||
|
`${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ
|
||||||
|
await runScan(true);
|
||||||
|
} catch (applyError) {
|
||||||
|
setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ"));
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||||
|
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
|
||||||
|
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
|
||||||
|
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
|
||||||
|
|
||||||
|
{/* §1 ตรวจสอบ Content Controls */}
|
||||||
|
<div className="mt-2 flex items-baseline justify-between gap-2">
|
||||||
|
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
|
||||||
|
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_SECONDARY} w-full justify-center`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={scanning}
|
||||||
|
onClick={() => void runScan(false)}
|
||||||
|
>
|
||||||
|
{scanning ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ScanSearch className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
สแกนช่องในเอกสาร
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
|
||||||
|
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
|
||||||
|
{duplicateTags.length > 0
|
||||||
|
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
|
||||||
|
: ""}
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!fields && !scanning ? (
|
||||||
|
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
|
||||||
|
) : null}
|
||||||
|
{fields && fields.length === 0 && !scanning ? (
|
||||||
|
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields && fields.length > 0 ? (
|
||||||
|
<ol className="relative">
|
||||||
|
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||||
|
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="text-xs text-ink-faint">{index + 1}.</span>
|
||||||
|
<span
|
||||||
|
className="break-all text-sm font-medium text-ink"
|
||||||
|
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
|
||||||
|
>
|
||||||
|
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
|
||||||
|
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_ACTION}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleView(field.id)}
|
||||||
|
>
|
||||||
|
<Crosshair className="size-3.5" aria-hidden />
|
||||||
|
ดูช่อง
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
|
||||||
|
{field.text.trim() === "" ? "(ว่าง)" : field.text}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* §2 กรอกค่าทดสอบ */}
|
||||||
|
{uniqueTags.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2" aria-busy={applying}>
|
||||||
|
{uniqueTags.map((tag, index) => (
|
||||||
|
<div key={tag} className="flex flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
|
||||||
|
{tag}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`wordaddin-template-value-${index}`}
|
||||||
|
className="genesis-input block w-full"
|
||||||
|
value={values[tag] ?? ""}
|
||||||
|
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} w-full justify-center`}
|
||||||
|
disabled={busy || !hasFilledValue}
|
||||||
|
onClick={() => void handleApply()}
|
||||||
|
>
|
||||||
|
<PenLine className="size-4" aria-hidden />
|
||||||
|
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : fields ? (
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
|
||||||
|
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
|
||||||
|
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_INSERT}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={insertingParagraph}
|
||||||
|
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
|
||||||
|
onClick={() => void handleInsertParagraph()}
|
||||||
|
>
|
||||||
|
{insertingParagraph ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CornerDownLeft className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
ย่อหน้า
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_INSERT}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={insertingBreak === "line"}
|
||||||
|
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
|
||||||
|
onClick={() => void handleInsertBreak("line")}
|
||||||
|
>
|
||||||
|
{insertingBreak === "line" ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<WrapText className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
แบ่งบรรทัด
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_INSERT}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={insertingBreak === "page"}
|
||||||
|
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
|
||||||
|
onClick={() => void handleInsertBreak("page")}
|
||||||
|
>
|
||||||
|
{insertingBreak === "page" ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<FilePlus className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
แบ่งหน้า
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplateToolsPanel;
|
||||||
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Download, FileText, LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
|
||||||
|
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
|
||||||
|
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? iso
|
||||||
|
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
|
||||||
|
const BTN_ACTION =
|
||||||
|
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
export function VersionHistoryPanel({
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
downloadingVersionId,
|
||||||
|
downloadingPdfVersionId,
|
||||||
|
onDownloadPdf,
|
||||||
|
onDownload,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
versions: VersionRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||||
|
downloadingVersionId: string | null;
|
||||||
|
downloadingPdfVersionId: string | null;
|
||||||
|
onDownloadPdf: (version: VersionRecord) => void;
|
||||||
|
onDownload: (version: VersionRecord) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="mt-4 mb-2 flex items-baseline justify-between">
|
||||||
|
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
|
||||||
|
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <Spinner label="กำลังโหลด…" /> : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !error && versions.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<ol className="relative">
|
||||||
|
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
|
||||||
|
{versions.length > 1 ? (
|
||||||
|
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
{versions.map((version, index) => {
|
||||||
|
const isLatest = index === 0;
|
||||||
|
return (
|
||||||
|
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||||
|
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
|
||||||
|
{isLatest ? (
|
||||||
|
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_ACTION}
|
||||||
|
disabled={anyDownloading}
|
||||||
|
aria-busy={downloadingVersionId === version.versionId}
|
||||||
|
onClick={() => onDownload(version)}
|
||||||
|
>
|
||||||
|
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
|
||||||
|
{downloadingVersionId === version.versionId ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Download className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
ดาวน์โหลด
|
||||||
|
</button>
|
||||||
|
{version.hasPdf ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_ACTION}
|
||||||
|
disabled={anyDownloading}
|
||||||
|
aria-busy={downloadingPdfVersionId === version.versionId}
|
||||||
|
onClick={() => onDownloadPdf(version)}
|
||||||
|
>
|
||||||
|
{downloadingPdfVersionId === version.versionId ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{version.message ? (
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
|
||||||
|
{version.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-0.5 text-xs text-ink-soft">
|
||||||
|
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionHistoryPanel;
|
||||||
131
components/wordaddin/WordAddinGate.tsx
Normal file
131
components/wordaddin/WordAddinGate.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
import { signInForDemo } from "@/wordaddin/services/authService";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import TaskpaneApp from "./TaskpaneApp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
|
||||||
|
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
|
||||||
|
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
|
||||||
|
* Office ที่ module top-level จึงปลอดภัย)
|
||||||
|
*
|
||||||
|
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
|
||||||
|
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
|
||||||
|
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
|
||||||
|
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
|
||||||
|
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
|
||||||
|
const ONREADY_FALLBACK_MS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||||
|
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||||
|
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
|
||||||
|
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
|
||||||
|
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
|
||||||
|
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
|
||||||
|
*/
|
||||||
|
let officeReadyPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function ensureOfficeJs(): Promise<boolean> {
|
||||||
|
if (!officeReadyPromise) {
|
||||||
|
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||||
|
if (typeof Office !== "undefined") {
|
||||||
|
void Office.onReady(() => resolve(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||||
|
const onReady = () => {
|
||||||
|
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||||
|
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||||
|
void Office.onReady(() => {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
script.onload = onReady;
|
||||||
|
script.onerror = () => {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
officeReadyPromise = null;
|
||||||
|
script.remove();
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
script.src = OFFICE_JS_SRC;
|
||||||
|
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
|
||||||
|
script.id = "office-js";
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return officeReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GateStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export function WordAddinGate() {
|
||||||
|
const [status, setStatus] = useState<GateStatus>("loading");
|
||||||
|
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
|
||||||
|
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
|
||||||
|
signInForDemo();
|
||||||
|
setDemo(demoParam);
|
||||||
|
setStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void ensureOfficeJs()
|
||||||
|
.then((ok) => {
|
||||||
|
if (!cancelled) setStatus(ok ? "ready" : "error");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
|
||||||
|
if (!cancelled) setStatus("error");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
|
||||||
|
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
|
||||||
|
</MessageBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <TaskpaneApp demo={demo ?? undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordAddinGate;
|
||||||
94
db/schema.ts
94
db/schema.ts
|
|
@ -253,12 +253,76 @@ export const annotationVersions = pgTable(
|
||||||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
(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) ──
|
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||||||
|
|
||||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||||||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||||||
|
export type DocumentRow = typeof documents.$inferSelect;
|
||||||
|
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||||||
|
|
||||||
export type AnnotationDTO = {
|
export type AnnotationDTO = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -371,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||||||
|
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||||||
|
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||||||
|
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||||||
|
export type DocumentVersionDTO = {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
createdAt: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdByUserName: string;
|
||||||
|
contentHash: string;
|
||||||
|
fields: Record<string, string> | null;
|
||||||
|
message: string | null;
|
||||||
|
hasPdf: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||||||
|
return {
|
||||||
|
versionId: row.id,
|
||||||
|
versionNumber: row.versionNumber,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
createdByUserId: row.userId,
|
||||||
|
createdByUserName: row.userName,
|
||||||
|
contentHash: row.contentHash,
|
||||||
|
fields: row.fields,
|
||||||
|
message: row.message,
|
||||||
|
hasPdf: row.pdfObjectKey !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
|
||||||
|
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
|
||||||
|
#
|
||||||
|
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
|
||||||
|
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
|
||||||
|
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
|
||||||
|
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: judes-note-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
|
||||||
|
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
|
||||||
|
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
|
||||||
|
POSTGRES_DB: ecourt-note
|
||||||
|
ports:
|
||||||
|
- "54322:5432"
|
||||||
|
volumes:
|
||||||
|
- judes-note-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
|
||||||
|
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
|
||||||
|
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
|
||||||
|
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
|
||||||
|
minio:
|
||||||
|
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
|
container_name: judes-note-minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
|
||||||
|
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
|
||||||
|
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
|
||||||
|
- "19001:9001" # console ดู bucket/object ผ่าน browser
|
||||||
|
volumes:
|
||||||
|
- judes-note-minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
|
||||||
|
judes-note-db-data:
|
||||||
|
name: judes-note-db-data
|
||||||
|
judes-note-minio-data:
|
||||||
|
name: judes-note-minio-data
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
FROM node:24-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
# postinstall รัน scripts/patch-pdf-worker.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||||
COPY scripts/patch-pdf-worker.mjs scripts/
|
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
|
||||||
# postinstall เขียน public/pdf.worker.min.mjs — ต้องมีโฟลเดอร์ public/ ก่อน ไม่งั้น npm ci ล้ม
|
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
|
||||||
RUN mkdir -p public && npm ci
|
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
# ─── Stage 2: build Next.js (output: standalone) ───
|
# ─── Stage 2: build Next.js (output: standalone) ───
|
||||||
FROM node:24-alpine AS builder
|
FROM node:24-alpine AS builder
|
||||||
|
|
@ -15,13 +16,27 @@ COPY . .
|
||||||
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
||||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||||
RUN node scripts/patch-pdf-worker.mjs
|
# + คัดลอก 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
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||||
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
|
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
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -30,10 +45,14 @@ ENV NODE_ENV=production \
|
||||||
PORT=3000 \
|
PORT=3000 \
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
# Next 14.2 รวม public/ (pdf.worker, ฟอนต์ Sarabun, samples PDF) ไว้ใน standalone แล้ว
|
# Next 14.2 standalone **ไม่รวม public/ และ .next/static เอง** — ต้องคัดลอกเองทั้งคู่
|
||||||
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
|
# (ก่อนหน้านี้เข้าใจผิดว่า Next รวม public/ ให้: จริง ๆ มันหลุดเข้ามาแค่บางส่วนผ่าน output
|
||||||
|
# file tracing ที่ตาม fs.readFile ของ server code (public/note จาก lib/server-pdf.ts,
|
||||||
|
# ฟอนต์ Sarabun จาก export route) — public/wordaddin/office-js ไม่มีใครอ่านผ่าน fs
|
||||||
|
# จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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
|
# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
|
|
@ -43,10 +62,10 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules
|
||||||
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
|
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
|
||||||
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
|
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
|
||||||
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
|
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
|
||||||
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
RUN mkdir -p public/note/uploads && chown nextjs:nodejs public/note/uploads
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
||||||
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
|
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
|
||||||
# seed-samples sync ตาราง files กับ public/samples/ ที่ bake อยู่ใน image (idempotent)
|
# seed-samples sync ตาราง files กับ public/note/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||||
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]
|
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
| สิ่งที่ต้องมี | ค่า |
|
| สิ่งที่ต้องมี | ค่า |
|
||||||
|---|---|
|
|---|---|
|
||||||
| URL | `https://<host>/docedit/note/api/external/documents` (แอป serve ใต้ basePath `/docedit/note`) |
|
| URL | `https://<host>/docedit/api/note/external/documents` (API อยู่ใต้ `app/api/note/**` ของ basePath `/docedit` — ต่างจากหน้า UI ที่อยู่ใต้ `/docedit/note/…`) |
|
||||||
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
|
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
|
||||||
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
|
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
|
||||||
| Content-Type | `application/json` |
|
| Content-Type | `application/json` |
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
|
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /docedit/note/api/external/documents HTTP/1.1
|
POST /docedit/api/note/external/documents HTTP/1.1
|
||||||
Host: <host>
|
Host: <host>
|
||||||
x-api-key: <API Key ที่ได้รับ>
|
x-api-key: <API Key ที่ได้รับ>
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
|
|
@ -191,7 +191,7 @@ error ทุกสถานะเป็น JSON รูปเดียวกั
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"fileId":"1234"}' \
|
-d '{"fileId":"1234"}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
Response (ตัดย่อ):
|
Response (ตัดย่อ):
|
||||||
|
|
@ -256,7 +256,7 @@ Response (ตัดย่อ):
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
|
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
|
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
|
||||||
|
|
@ -266,7 +266,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
|
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
|
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
|
||||||
|
|
@ -276,7 +276,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||||
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
|
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
|
||||||
"http://localhost:3000/docedit/note/api/external/documents"
|
"http://localhost:3000/docedit/api/note/external/documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
|
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
|
||||||
|
|
|
||||||
214
docs/PLAN-ADDIN-AUTH.md
Normal file
214
docs/PLAN-ADDIN-AUTH.md
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
# PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`)
|
||||||
|
|
||||||
|
> วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md`
|
||||||
|
|
||||||
|
## 0. บริบท/เป้าหมาย
|
||||||
|
|
||||||
|
เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp
|
||||||
|
`token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement:
|
||||||
|
|
||||||
|
- e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256`
|
||||||
|
- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network)
|
||||||
|
- **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage
|
||||||
|
ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป
|
||||||
|
- ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ —
|
||||||
|
ไม่ต้องรองรับสองแบบพร้อมกัน)
|
||||||
|
|
||||||
|
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27)
|
||||||
|
|
||||||
|
| # | เรื่อง | ข้อตกลง |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | addin ยืนยันกับ `/token` อย่างไร | **ขยาย `POST /api/documents/token` รับ ticket เพิ่ม** — body `{ticket_data, HmacSha256}` server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า `x-api-key` เดิม (e-Court server-to-server) ใช้ต่อคู่กัน |
|
||||||
|
| D2 | โค้ดซ้ำสองฝั่ง | แชร์ส่วน pure ผ่าน **`utils/docver-ticket.ts`** (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก `lib/`") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = `node:crypto` |
|
||||||
|
| D3 | client-side HMAC | ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET` — **ยอมรับว่าค่าใน bundle สกัดได้** จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify |
|
||||||
|
| D4 | รูปแบบ HMAC | sign บน **string ดิบของ `ticket_data` ตามที่อ่านได้จาก property** (ห้าม trim/normalize) · digest เป็น **hex lowercase 64 ตัว** — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน) |
|
||||||
|
| D5 | `tid` | = `template` ของ claims (`tid` optional → `template: null`) · map `uid→userid`, `uname→username`, `fid` ตรง ๆ |
|
||||||
|
| D6 | token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด | เก็บ `exp` ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → **renew อัตโนมัติ** · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access |
|
||||||
|
| D7 | localStorage | key เดียว `wordaddin-auth` เก็บ JSON `{v:1, userId, userName, tokens: {[fid]: {token, exp}}}` · เจอ user ใหม่ = **เคลียร์ทั้งก้อน** (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern `versioningService`) |
|
||||||
|
| D8 | "disable Add-in ทั้งหมด" | = phase `no-access` ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ |
|
||||||
|
| D9 | ช่วง mock (`NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง) | addin ยิงขอ token จริงที่ same-origin `withBasePath("/api/documents/token")` (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ |
|
||||||
|
| D10 | เครื่องมือทดสอบ | มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (`scripts/make-ticket-docx.mjs`) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย |
|
||||||
|
|
||||||
|
## 2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp)
|
||||||
|
|
||||||
|
Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive):
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}
|
||||||
|
HmacSha256 = <hex 64 ตัว lowercase> = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim)
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | type | เงื่อนไข |
|
||||||
|
|---|---|---|
|
||||||
|
| `exp` | int (unix วินาที) | บังคับ · `exp <= now` = หมดอายุ |
|
||||||
|
| `fid` | string | บังคับ · trim 1–200 · เป็น `docId` ของทุก operation ของกลุ่ม docver |
|
||||||
|
| `uid` | string | บังคับ · trim 1–100 |
|
||||||
|
| `uname` | string | บังคับ · trim 1–200 |
|
||||||
|
| `tid` | string | optional · ≤100 · template ต้นทาง (ไม่มี = `template: null`) |
|
||||||
|
|
||||||
|
- field อื่นปนมา = reject (allowlist ตาม convention ของ repo)
|
||||||
|
- secret ปัจจุบัน = `"super_secret"` — **e-Court + `ADDIN_TICKET_SECRET` (server) +
|
||||||
|
`NEXT_PUBLIC_ADDIN_TICKET_SECRET` (client) ต้องตรงกันทั้งสาม**
|
||||||
|
- ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data`
|
||||||
|
ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access)
|
||||||
|
|
||||||
|
## 3. ฝั่ง server — `POST /api/documents/token` รับ ticket เพิ่ม
|
||||||
|
|
||||||
|
Branch ตาม header (ตัวตั้ง = `x-api-key`):
|
||||||
|
|
||||||
|
| เคส | พฤติกรรม |
|
||||||
|
|---|---|
|
||||||
|
| มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) |
|
||||||
|
| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` |
|
||||||
|
| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) |
|
||||||
|
| HMAC ผิด / parse ticket ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) |
|
||||||
|
| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 |
|
||||||
|
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
|
||||||
|
|
||||||
|
- การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern
|
||||||
|
`verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ
|
||||||
|
- middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้
|
||||||
|
|
||||||
|
## 4. โค้ดแชร์ — `utils/docver-ticket.ts` (pure, ไม่มี dependency เลย)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string }
|
||||||
|
export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string }
|
||||||
|
export function isTicketExpired(exp: number, nowSec?: number): boolean // exp <= now
|
||||||
|
export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$
|
||||||
|
export function timingSafeEqualHex(a: string, b: string): boolean // constant-time บน hex ที่ normalize แล้ว
|
||||||
|
export type HmacSha256Hex = (message: string, secret: string) => Promise<string> // adapter ที่สองฝั่งฉีด
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน —
|
||||||
|
schema drift กันได้จากฝั่งเดียว
|
||||||
|
|
||||||
|
## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement)
|
||||||
|
|
||||||
|
`TaskpaneApp.detect()` → `authService.signInWithTicket({ticketData, ticketHmac})`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties)
|
||||||
|
2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์"
|
||||||
|
parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข"
|
||||||
|
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
|
||||||
|
3. session = localStorage["wordaddin-auth"] (fallback memory)
|
||||||
|
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
|
||||||
|
· ticket หมดอายุ → no-access "ticket หมดอายุ"
|
||||||
|
· เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
|
||||||
|
· เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
|
||||||
|
5. uid ตรงกัน (คนเดิม):
|
||||||
|
· tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
|
||||||
|
— token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
|
||||||
|
— token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
|
||||||
|
· tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
|
||||||
|
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
|
||||||
|
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
|
||||||
|
```
|
||||||
|
|
||||||
|
- `authService` surface ใหม่: `signInWithTicket` / `getAccessToken` (token ของ fid ปัจจุบัน) /
|
||||||
|
`renewTokenFromTicket` / `getUsername` — ถอด `signInFromDocument` + `decodeJwtField` สำหรับ uid
|
||||||
|
(ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ `exp` ของ JWT ตอนเก็บ token)
|
||||||
|
- `apiClient` ตอนโดน 401: `renewTokenFromTicket()` → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี
|
||||||
|
ticket = `AuthRequiredError` (UI ไป no-access — เดิมมีอยู่แล้ว)
|
||||||
|
- `?addindemo` ยังใช้ได้ (`signInForDemo` คงเดิม)
|
||||||
|
|
||||||
|
## 6. ไฟล์ที่สร้าง/แต้/ถอด
|
||||||
|
|
||||||
|
**สร้าง**
|
||||||
|
- `utils/docver-ticket.ts` — §4
|
||||||
|
- `lib/addin-ticket.ts` — server-only: `addinTicketSecret()` (env `ADDIN_TICKET_SECRET` trim หรือ default
|
||||||
|
`"super_secret"`) + `verifyAddinTicket(ticketData, hmacHex, secret, nowSec?)` (HMAC `node:crypto` +
|
||||||
|
timing-safe + parse + exp) + `ticketToDocverClaims(ticket)` — ห้าม import `@/db`/minio (unit test รันได้เปล่า ๆ)
|
||||||
|
- `wordaddin/services/ticketService.ts` — client: `verifyTicketHmacWeb` (crypto.subtle), session store
|
||||||
|
(`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken`
|
||||||
|
(POST `withBasePath("/api/documents/token")` body ticket)
|
||||||
|
- `scripts/make-ticket-docx.mjs` — §9
|
||||||
|
- `tests/utils/docver-ticket.test.ts`, `tests/lib/addin-ticket.test.ts` (vitest — pattern `tests/lib/docver-token.test.ts`)
|
||||||
|
|
||||||
|
**แต้**
|
||||||
|
- `app/api/documents/token/route.ts` — branch ตาม §3
|
||||||
|
- `wordaddin/config.ts` — `ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"`
|
||||||
|
- `wordaddin/services/metadataService.ts` — ถอด `ECOURT_TOKEN_PROPERTY`/`ECOURT_OWNER_PROPERTY` →
|
||||||
|
`TICKET_DATA_PROPERTY = "ticket_data"` + `TICKET_HMAC_PROPERTY = "HmacSha256"` (`readTrackedProperties`
|
||||||
|
อ่านคู่นี้แทน `ecourtToken`/`ecourtOwner`)
|
||||||
|
- `wordaddin/services/authService.ts` — rewrite ตาม §5
|
||||||
|
- `wordaddin/services/apiClient.ts` — renew-retry ตอน 401
|
||||||
|
- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access 3 แบบ
|
||||||
|
- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket)
|
||||||
|
- `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md`
|
||||||
|
|
||||||
|
**ถอด**
|
||||||
|
- ระบบ `token`/`owner` ทั้งเส้น (properties + `signInFromDocument` + `decodeJwtField` ส่วน uid) —
|
||||||
|
แทนด้วย ticket เต็มตัว
|
||||||
|
|
||||||
|
## 7. Env
|
||||||
|
|
||||||
|
| env | default | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADDIN_TICKET_SECRET` | `SECRET` ของกลุ่ม template → `"super_secret"` | server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ `ADDIN_TICKET_SECRET` → `SECRET` (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → `"super_secret"` · **production ต้องตั้งค่าใหม่** (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ |
|
||||||
|
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน |
|
||||||
|
|
||||||
|
> หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template
|
||||||
|
> (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม
|
||||||
|
> `exp` แล้ว HMAC hex lowercase ผ่าน `lib/lib-utils.ts` + stamp ลงไฟล์ด้วย
|
||||||
|
> `setCustomDocProps` ของ `lib/lib-tmpl.ts` อ่าน env `SECRET` กับ `TICKET_SEC` default
|
||||||
|
> 300 วิ — ไฟล์ยังมี property `Ed25519` เพิ่มด้วยแต่ out of scope ของแผนนี้)
|
||||||
|
|
||||||
|
(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที
|
||||||
|
แต่ comment เตือน production ชัด ๆ ทั้ง `.env.example` และแผนนี้)
|
||||||
|
|
||||||
|
## 8. Edge cases
|
||||||
|
|
||||||
|
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
|
||||||
|
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
|
||||||
|
- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว
|
||||||
|
(เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้)
|
||||||
|
- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
|
||||||
|
- **exp == now** → หมดอายุ
|
||||||
|
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
|
||||||
|
- **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น
|
||||||
|
state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว
|
||||||
|
- **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ)
|
||||||
|
|
||||||
|
## 9. สคริปต์ทดสอบ — `scripts/make-ticket-docx.mjs`
|
||||||
|
|
||||||
|
Dev tool รันมือ `node scripts/make-ticket-docx.mjs <file.docx> [options]`:
|
||||||
|
|
||||||
|
- option: `--fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-secret>` และ
|
||||||
|
`--tracked` (stamp `SavedByAddIn`+`DocumentTrackingId` เพื่อทดสอบ phase tracking ด้วย)
|
||||||
|
- ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง `docProps/custom.xml`
|
||||||
|
ผ่าน **pizzip** (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี
|
||||||
|
(พร้อม override ใน `[Content_Types].xml`) · เขียนทับไฟล์เดิม**เฉพาะเมื่อส่ง `--in-place`**
|
||||||
|
ไม่งั้นเขียนไฟล์ใหม่ `<name>.ticket.docx` (กฎ non-destructive ผูกกับ `public/` เท่านั้นแต่ถือหลักเดียวกัน)
|
||||||
|
- default secret/exp สอดคล้อง .env ตัวอย่าง
|
||||||
|
|
||||||
|
## 10. Verify criteria
|
||||||
|
|
||||||
|
1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง)
|
||||||
|
2. `npx tsc --noEmit` ผ่าน
|
||||||
|
3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม
|
||||||
|
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
|
||||||
|
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
|
||||||
|
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" ·
|
||||||
|
exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) ·
|
||||||
|
ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ ·
|
||||||
|
เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
|
||||||
|
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
||||||
|
|
||||||
|
## 11. ลำดับงาน
|
||||||
|
|
||||||
|
1. `utils/docver-ticket.ts` + `tests/utils/docver-ticket.test.ts`
|
||||||
|
2. `lib/addin-ticket.ts` + `tests/lib/addin-ticket.test.ts`
|
||||||
|
3. `app/api/documents/token/route.ts` + `lib/openapi.ts` → verify 10.3 ด้วย curl
|
||||||
|
4. `wordaddin/config.ts` + `metadataService.ts` (properties)
|
||||||
|
5. `ticketService.ts` + `authService.ts` (rewrite)
|
||||||
|
6. `TaskpaneApp.tsx` (detect + ข้อความ) + `apiClient.ts` (renew-retry)
|
||||||
|
7. `scripts/make-ticket-docx.mjs`
|
||||||
|
8. env/docs/CLAUDE.md sync → verify 10.1–10.5
|
||||||
|
|
||||||
|
## 12. Out of scope
|
||||||
|
|
||||||
|
- ~~ปลด mock `versioningService`~~ (ทำแล้ว 2026-10-27 — `versioningService` ยิงกลุ่ม docver จริงผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo` ดู `docs/WORDADDIN-DEV.md`)
|
||||||
|
- ~~ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid)~~ (ยุติแล้ว 2026-10-27 — เอกสารระบุด้วย fid ใน token ฝั่งเดียว; พารามิเตอร์ documentId ของ versioningService เหลือตาม signature เดิมไม่ส่งถึง server)
|
||||||
|
- UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)
|
||||||
231
docs/PLAN-DOCVER.md
Normal file
231
docs/PLAN-DOCVER.md
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||||||
|
|
||||||
|
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
|
||||||
|
> รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService`
|
||||||
|
> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`)
|
||||||
|
|
||||||
|
## 0. บริบท/เป้าหมาย
|
||||||
|
|
||||||
|
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
|
||||||
|
`wordaddin/services/versioningService.ts`):
|
||||||
|
|
||||||
|
- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid`
|
||||||
|
(string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก
|
||||||
|
endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
|
||||||
|
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
|
||||||
|
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
||||||
|
response)
|
||||||
|
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
||||||
|
- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22
|
||||||
|
— เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
|
||||||
|
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
|
||||||
|
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
|
||||||
|
พาหะ identity เดียว
|
||||||
|
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
|
||||||
|
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
|
||||||
|
ของ N10) · ไม่มี CORS
|
||||||
|
|
||||||
|
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
||||||
|
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
||||||
|
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
|
||||||
|
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
|
||||||
|
|
||||||
|
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
|
||||||
|
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
|
||||||
|
(client-only zone) type define เองใน `db/schema.ts`/`lib`
|
||||||
|
|
||||||
|
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
||||||
|
middleware เหมือน `api/template`)
|
||||||
|
|
||||||
|
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
|
||||||
|
|
||||||
|
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
|
||||||
|
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
|
||||||
|
|
||||||
|
**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header
|
||||||
|
`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties
|
||||||
|
ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน
|
||||||
|
`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`)
|
||||||
|
แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`)
|
||||||
|
· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ
|
||||||
|
เดียวกัน · body ผิดรูป = 400)
|
||||||
|
|
||||||
|
JSON body (allowlist — key อื่น = 400 กัน typo):
|
||||||
|
|
||||||
|
| field | type | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
|
||||||
|
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
|
||||||
|
| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid |
|
||||||
|
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
|
||||||
|
|
||||||
|
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
|
||||||
|
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
|
||||||
|
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
|
||||||
|
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
|
||||||
|
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
|
||||||
|
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
|
||||||
|
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
|
||||||
|
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
|
||||||
|
|
||||||
|
### `POST /api/documents/versions` — อัปโหลด version ใหม่
|
||||||
|
|
||||||
|
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ
|
||||||
|
`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป
|
||||||
|
|
||||||
|
multipart/form-data:
|
||||||
|
|
||||||
|
| field | type | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
||||||
|
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
||||||
|
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
|
||||||
|
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) |
|
||||||
|
| `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 แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง")
|
||||||
|
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
|
||||||
|
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
|
||||||
92
docs/WORDADDIN-DEV.md
Normal file
92
docs/WORDADDIN-DEV.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
|
||||||
|
|
||||||
|
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
|
||||||
|
|
||||||
|
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
|
||||||
|
|
||||||
|
สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer จาก ticket: Save & Version ขึ้น MinIO จริง ดาวน์โหลดเปิด presigned URL ในเบราว์เซอร์; เหลือ mock เฉพาะโหมด `?addindemo` เพื่อดู UI ใน browser โดยไม่ต้องมี backend — ดูหัว "การรันกับ backend จริง")
|
||||||
|
|
||||||
|
## ไฟล์หลัก
|
||||||
|
|
||||||
|
| ที่ | คือ |
|
||||||
|
|---|---|
|
||||||
|
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
|
||||||
|
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
|
||||||
|
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler |
|
||||||
|
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) |
|
||||||
|
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
|
||||||
|
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
|
||||||
|
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| ตัวแปร | default | ใช้เมื่อไหร่ |
|
||||||
|
|---|---|---|
|
||||||
|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) |
|
||||||
|
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) |
|
||||||
|
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** |
|
||||||
|
|
||||||
|
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
|
||||||
|
|
||||||
|
manifest ต้องชี้ https — 2 ทางเลือก:
|
||||||
|
|
||||||
|
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
|
||||||
|
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
|
||||||
|
|
||||||
|
## Sideload ใน Word Desktop (Windows)
|
||||||
|
|
||||||
|
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
|
||||||
|
|
||||||
|
1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word
|
||||||
|
2. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
|
||||||
|
3. ชื่อไฟล์ไม่สำคัญ — Word สแกน `*.xml` ทุกไฟล์ในโฟลเดอร์ โดย `manifest.template.xml` คือ dev manifest อยู่แล้ว (URL localhost:3000) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout อยู่เสมอ — **ห้ามวาง xml manifest อื่นเพิ่มในโฟลเดอร์นี้** (GUID เดียวกันสองไฟล์ = Word สับสน)
|
||||||
|
|
||||||
|
เครื่องที่ไม่มี repo (production/เครื่องผู้ใช้):
|
||||||
|
|
||||||
|
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
|
||||||
|
2. เอาไฟล์ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
|
||||||
|
3. ทำขั้น Trust Center ข้างบนด้วยโฟลเดอร์นั้น > restart Word > **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
|
||||||
|
|
||||||
|
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
|
||||||
|
|
||||||
|
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
|
||||||
|
|
||||||
|
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
|
||||||
|
|
||||||
|
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม้นอก host (host เป็น null) → `detect()` อ่าน custom properties ไม่ได้ (ไม่มี `Word.run`) → เห็น `no-access` — ตรวจ state machine ได้แค่นี้ใน browser ปกติ ส่วน phase `untracked`/`tracking` ต้องใช้ demo ด้านล่าง (หรือ sideload ใน Word จริง)
|
||||||
|
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
|
||||||
|
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
|
||||||
|
|
||||||
|
## ทดสอบ e-Court ticket (auth ทางเข้าเดียว)
|
||||||
|
|
||||||
|
ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ
|
||||||
|
|
||||||
|
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# สร้าง <file>.ticket.docx (ไม่แตะไฟล์เดิม) exp +5 นาที เท่า TICKET_SEC ของตัว stamp
|
||||||
|
node scripts/make-ticket-docx.mjs path/to/file.docx --fid DEMO_FID_001 --uid oom --uname "สรวิชญ์" --tid T12
|
||||||
|
# ดู custom properties ที่มีในไฟล์
|
||||||
|
node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list
|
||||||
|
# ทดสอบ phase tracking ด้วย (stamp SavedByAddIn + DocumentTrackingId เพิ่ม)
|
||||||
|
node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place
|
||||||
|
```
|
||||||
|
|
||||||
|
แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7
|
||||||
|
|
||||||
|
หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์
|
||||||
|
|
||||||
|
## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27)
|
||||||
|
|
||||||
|
- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
|
||||||
|
- **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS)
|
||||||
|
- **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400)
|
||||||
|
- **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้)
|
||||||
|
- **mock เหลือเฉพาะโหมด `?addindemo`** (dev-only — ดู UI ใน browser ไม่ต้องมี backend: version เก็บในเครื่อง metadata ใน localStorage `wordaddin-mock-versions`, download-link คืน about:blank — เคลียร์ด้วยการลบ key นี้หรือ clear storage ของ origin)
|
||||||
|
- ชุด unit test ของ services: ต้นฉบับ jest อยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` — ฝั่งนี้เขียนใหม่เป็น vitest เฉพาะส่วนที่เป็นโค้ดใหม่ (ticket auth + versioningService สองโหมด ที่ `tests/wordaddin/`)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ runner stage คัดลอก `public/` เข้า image เอง (`COPY /app/public ./public` — **Next 14.2 standalone ไม่รวม `public/` ให้อัตโนมัติ** ต้อง copy เองตามเอกสาร deploy ของ Next; เคยเข้าใจผิดว่า Next รวมให้ เพราะบางไฟล์หลุดเข้า image ผ่าน output file tracing ของ Next ที่ตาม `fs.readFile` ใน server code — แต่ `public/wordaddin/office-js` ไม่มีใครอ่านผ่าน fs จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||||
|
|
||||||
|
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile
|
||||||
319
docs/addin-ui-flow.md
Normal file
319
docs/addin-ui-flow.md
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
|
||||||
|
|
||||||
|
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
|
||||||
|
|
||||||
|
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. State machine หลัก
|
||||||
|
|
||||||
|
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
|
||||||
|
|
||||||
|
ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
|
||||||
|
|
||||||
|
**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`)
|
||||||
|
|
||||||
|
```
|
||||||
|
checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access
|
||||||
|
│
|
||||||
|
├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked
|
||||||
|
└─(ticket ผ่าน + tracked doc)────────→ tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
การเปลี่ยน phase อื่นๆ:
|
||||||
|
|
||||||
|
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
|
||||||
|
- ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access`
|
||||||
|
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ)
|
||||||
|
|
||||||
|
State ที่ UI ถือนอกเหนือจาก phase:
|
||||||
|
|
||||||
|
| State | ความหมาย |
|
||||||
|
|---|---|
|
||||||
|
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
|
||||||
|
| `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header |
|
||||||
|
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
|
||||||
|
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
|
||||||
|
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
|
||||||
|
| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) |
|
||||||
|
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
|
||||||
|
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
|
||||||
|
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
|
||||||
|
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
|
||||||
|
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
|
||||||
|
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
|
||||||
|
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
|
||||||
|
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
|
||||||
|
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UI แต่ละ phase
|
||||||
|
|
||||||
|
### Header (ทุก phase)
|
||||||
|
|
||||||
|
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
|
||||||
|
- ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title`
|
||||||
|
|
||||||
|
### `checking`
|
||||||
|
|
||||||
|
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
|
||||||
|
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
|
||||||
|
|
||||||
|
### `no-access`
|
||||||
|
|
||||||
|
- ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้"
|
||||||
|
- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ):
|
||||||
|
- ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"
|
||||||
|
- ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย"
|
||||||
|
- ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"
|
||||||
|
- ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง"
|
||||||
|
- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น
|
||||||
|
|
||||||
|
### `untracked`
|
||||||
|
|
||||||
|
เรียงจากบนลงล่าง:
|
||||||
|
|
||||||
|
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
|
||||||
|
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
|
||||||
|
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
|
||||||
|
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
|
||||||
|
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
|
||||||
|
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
|
||||||
|
7. `flowError` → MessageBar **error**
|
||||||
|
|
||||||
|
### `tracking`
|
||||||
|
|
||||||
|
เรียงจากบนลงล่าง:
|
||||||
|
|
||||||
|
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
|
||||||
|
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
|
||||||
|
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
|
||||||
|
4. `flowError` → MessageBar **error**
|
||||||
|
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
|
||||||
|
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
|
||||||
|
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
|
||||||
|
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
|
||||||
|
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
|
||||||
|
|
||||||
|
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
|
||||||
|
|
||||||
|
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
|
||||||
|
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
|
||||||
|
- สถานะตามลำดับ:
|
||||||
|
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
|
||||||
|
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
|
||||||
|
- `saveError` → MessageBar **error**
|
||||||
|
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว
|
||||||
|
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
|
||||||
|
|
||||||
|
พฤติกรรมปุ่ม:
|
||||||
|
|
||||||
|
| ปุ่ม | Flow |
|
||||||
|
|---|---|
|
||||||
|
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
|
||||||
|
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
|
||||||
|
|
||||||
|
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
|
||||||
|
|
||||||
|
| Prop | Save & Version | Start Versioning |
|
||||||
|
|---|---|---|
|
||||||
|
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
|
||||||
|
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
|
||||||
|
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
|
||||||
|
|
||||||
|
ภายใน dialog:
|
||||||
|
|
||||||
|
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
|
||||||
|
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
|
||||||
|
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
|
||||||
|
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
|
||||||
|
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
|
||||||
|
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
|
||||||
|
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
|
||||||
|
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `VersionHistoryPanel` (FR-4)
|
||||||
|
|
||||||
|
- Header: icon history + "Version history" (semibold)
|
||||||
|
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
|
||||||
|
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
|
||||||
|
- `Badge v{versionNumber}` (สี brand)
|
||||||
|
- timestamp รูปแบบ medium date + short time ตาม locale
|
||||||
|
- `โดย {createdByUserId}`
|
||||||
|
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
|
||||||
|
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
|
||||||
|
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
|
||||||
|
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
|
||||||
|
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Flow หลักพร้อมข้อความ progress
|
||||||
|
|
||||||
|
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
|
||||||
|
|
||||||
|
### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่
|
||||||
|
|
||||||
|
1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
|
||||||
|
2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ
|
||||||
|
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
|
||||||
|
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
|
||||||
|
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
|
||||||
|
|
||||||
|
### Start Versioning (FR-1) — จาก `untracked`
|
||||||
|
|
||||||
|
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
|
||||||
|
|
||||||
|
1. `กำลังบันทึก metadata ของเอกสาร…`
|
||||||
|
2. `กำลังอัปเดต version history ในไฟล์…`
|
||||||
|
3. `กำลังบันทึกไฟล์…`
|
||||||
|
4. `กำลังอ่านค่า fields จากเอกสาร…`
|
||||||
|
5. `กำลังอัปโหลด version 1…`
|
||||||
|
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
|
||||||
|
7. `กำลังอัปโหลด version…`
|
||||||
|
|
||||||
|
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
|
||||||
|
|
||||||
|
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Save & Version (FR-2) — จาก `tracking`
|
||||||
|
|
||||||
|
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
|
||||||
|
|
||||||
|
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
|
||||||
|
|
||||||
|
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Download version (FR-4/FR-7)
|
||||||
|
|
||||||
|
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Auto-save (FR-17)
|
||||||
|
|
||||||
|
- เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้)
|
||||||
|
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
|
||||||
|
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
|
||||||
|
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
|
||||||
|
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
|
||||||
|
|
||||||
|
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
|
||||||
|
|
||||||
|
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
|
||||||
|
|
||||||
|
multipart/form-data:
|
||||||
|
|
||||||
|
| field | type | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
|
||||||
|
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
|
||||||
|
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
|
||||||
|
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
|
||||||
|
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
|
||||||
|
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
|
||||||
|
|
||||||
|
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
|
||||||
|
|
||||||
|
### `GET /api/documents/{documentId}/versions` — history
|
||||||
|
|
||||||
|
response: `VersionRecord[]` เรียงใหม่ → เก่า:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface VersionRecord {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
createdAt: string; // ISO-8601
|
||||||
|
createdByUserId: string;
|
||||||
|
contentHash: string; // SHA256 hex
|
||||||
|
message: string | null; // commit message (FR-13)
|
||||||
|
hasPdf?: boolean;
|
||||||
|
fields: FieldsSnapshot | null;
|
||||||
|
}
|
||||||
|
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
|
||||||
|
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
|
||||||
|
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
|
||||||
|
|
||||||
|
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
|
||||||
|
|
||||||
|
### Auth — e-Court ticket (ไม่มี login)
|
||||||
|
|
||||||
|
ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md)
|
||||||
|
|
||||||
|
> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. กติกา error ที่ UI ต้องรองรับ
|
||||||
|
|
||||||
|
1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
|
||||||
|
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
|
||||||
|
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
|
||||||
|
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. ข้อควรระวังที่กระทบหน้าตา UI
|
||||||
|
|
||||||
|
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
|
||||||
|
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
|
||||||
|
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
|
||||||
|
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
|
||||||
|
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
|
||||||
|
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
|
||||||
|
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. แท็บ "เครื่องมือ Template" (2026-10-19)
|
||||||
|
|
||||||
|
เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน
|
||||||
|
|
||||||
|
### §1 ตรวจสอบ Content Controls
|
||||||
|
|
||||||
|
- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง
|
||||||
|
- สรุปหัวกลุ่ม: "N ช่อง"
|
||||||
|
- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag"
|
||||||
|
- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร")
|
||||||
|
|
||||||
|
### §2 กรอกค่าทดสอบ
|
||||||
|
|
||||||
|
- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-<index>`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ tag นั้น + ปุ่ม primary `เขียนค่าลงเอกสาร` (icon PenLine → "กำลังเขียนค่า…" ตอนรัน)
|
||||||
|
- **ช่องที่เว้นว่าง = ไม่ถูกแตะ** (กันเผลอล้างค่าจริง) · เขียนทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่องแบบ mail merge) · อ่าน id+tag สดรอบเดียวแล้วเขียน**แยก Word.run ราย tag** — tag ที่ Word ปฏิเสธ fail เฉพาะตัว เหลืออื่นยังเขียนได้ (ผลลัพธ์: `appliedCount` = จำนวนช่องที่เขียนจริง + `missingTags` ไม่พบช่อง + `failures` พร้อม code/errorLocation จาก OfficeExtension.Error) · UI รวมเป็น warning พร้อมชี้นำ "ถ้าเอกสารถูกล็อค (Review → Restrict Editing) ให้ปลดก่อน" — เคสจริงที่เจอ: error `0x800A16C6` บนเอกสารที่ `make-protected.mjs` ล็อคไว้ (readOnly + enforcement)
|
||||||
|
- ผลลัพธ์: success "เขียนค่าทดสอบลงแล้ว N ช่อง" (N = จำนวนช่องที่เขียนจริง — tag ซ้ำหลายช่องนับทุกช่อง) + warning missingTags ถ้ามี → **สแกนใหม่โดยคงค่าฟอร์ม** (เฉพาะ tag ที่ยังอยู่ในเอกสาร — tag ที่ถูกลบไปตัดทิ้ง) ให้แก้ต่อ · ปุ่ม disabled เมื่อยังไม่มีช่องที่กรอกค่าเลย · หลังเขียนสำเร็จ `notifyDocumentChanged()` ตั้ง debounce auto-save 3 วิ ส่วน banner FR-3 ตั้งเมื่อ periodic tick ถัดไปของ autoSaveService เห็น document dirty (พฤติกรรมเดิมของ service — ไม่ใช่ทันที)
|
||||||
|
|
||||||
|
### §3 แทรกย่อหน้า / ตัวแบ่ง (2026-10-19)
|
||||||
|
|
||||||
|
- 3 ปุ่มแนวตั้ง (icon บน label ล่าง, grid 3 คอลัมน์, `title` อธิบายเต็ม): **ย่อหน้า** (CornerDownLeft — `insertParagraph("", "After")` เหมือนกด Enter) · **แบ่งบรรทัด** (WrapText — `insertBreak("Line", "After")` เหมือน Shift+Enter จัดที่อยู่/รายการในย่อหน้าเดิม) · **แบ่งหน้า** (FilePlus — เหมือน Ctrl+Enter เอกสารหลายหน้า: `insertBreak("Page", "After")` ก่อน ถ้า host ปฏิเสธ E_FAIL 0x80004005 (บางบริบท เช่น cursor ในตาราง — เคสจริงบน T3 2026-10-19) สลับไป `insertOoxml` แบบ w:p สมบูรณ์ครอบ `<w:br w:type="page"/>` — fragment ต้องเป็น block-level เต็มตัว ใช้ run เดี่ยวก็ยัง E_FAIL)
|
||||||
|
- กดแล้วทำที่ cursor ทันที (ไม่มี success message — เห็นผลในเอกสารเลย) · icon สลับเป็น spinner ตอนรันตัวนั้น · ทุกปุ่ม disable ด้วย `busy` ร่วมกับเครื่องมืออื่น · fallback error: "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ"
|
||||||
|
|
||||||
|
### หมายเหตุ: ไม่มีปุ่มสลับ formatting marks (และไม่มีชุดแทรกอักขระพิเศษ)
|
||||||
|
|
||||||
|
- **Office.js ไม่มี API สลับแสดง/ซ่อน formatting marks** (¶ ท้ายย่อหน้า/จุดช่องว่าง) — เป็น application setting ของ Word ที่ WordApi ไม่เปิดให้ add-in แตะ ผู้ใช้กด **Ctrl+Shift+8** หรือปุ่ม **¶ บนแท็บ Home** ของ Word เอง (workflow จริงของผู้ใช้: แทรกย่อหน้าเพิ่ม → เปิด marks ตรวจ → ซ่อนกลับ — จึงมีปุ่มแทรกย่อหน้าให้ ส่วน marks ใช้ของ Word)
|
||||||
|
- เคยมีชุด "แทรกอักขระพิเศษ" (เลขไทย ๐–๙ + สัญลักษณ์) ต่อท้าย panel แล้ว**ถอดออก 2026-10-19** ตามคำขอผู้ใช้ — ที่ต้องการจริงคือ formatting marks ซึ่งทำผ่าน add-in ไม่ได้ (อ่านบันทึกเต็มใน git history รอบนั้น)
|
||||||
|
- เคยมีเครื่องมือ **"สร้างช่องจากข้อความที่เลือก"** (dialog ตั้ง tag + `insertContentControl` ครอบ selection + `validateTag` + `TagInputDialog` + unit test ของ validateTag) แล้ว**ถอดออกทั้งเส้น 2026-10-19** ตามคำขอผู้ใช้ — การสร้างช่องใหม่ทำใน Word ด้วย Developer tab แทน
|
||||||
|
|
||||||
|
### ข้อความ error กลางของ panel (MessageBar intent error)
|
||||||
|
|
||||||
|
- สแกนพัง: fallback "สแกนช่องในเอกสารไม่สำเร็จ" · ดูช่องพัง: "ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่" / fallback "เลือกช่องไม่สำเร็จ" · เขียนค่าพัง: fallback "เขียนค่าลงเอกสารไม่สำเร็จ" · แทรกพัง: fallback "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" (แบ่งหน้าที่พังทั้งสองวิธีจะรวมสาเหตุของทั้ง insertBreak และ OOXML fallback ในข้อความเดียว)
|
||||||
|
|
||||||
|
### พฤติกรรมใน browser ธรรมดา / `?addindemo`
|
||||||
|
|
||||||
|
ทุกปุ่มที่แตะเอกสาร (`สแกน`/`ดูช่อง`/`เขียนค่า`/`ย่อหน้า`/`แบ่งบรรทัด`/`แบ่งหน้า`) จบที่ error MessageBar ("Word is not defined") — ตามปรัชญา demo ของ repo เหมือนปุ่ม Save & Version ส่วน toggle/สลับแท็บ/ฟอร์มค่าทดสอบ ทดสอบได้ครบใน browser
|
||||||
44
docs/requirement/addin-workflow.md
Normal file
44
docs/requirement/addin-workflow.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# ขั้นตอนการทำงานของ Addin
|
||||||
|
|
||||||
|
## 1. เมื่อเปิดเอกสาร
|
||||||
|
|
||||||
|
- อ่าน custom property ของ word
|
||||||
|
1. ticket_data ซึ่งจะมีข้อมูลเบื้องต้น ดังนี้
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"exp": 1793067433,
|
||||||
|
"fid": "T12_FD155_3s-long_ZZZ",
|
||||||
|
"tid": "T12",
|
||||||
|
"uid": "oom",
|
||||||
|
"uname": "สรวิชญ์",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. นำค่า ticket_data ที่เป็น string ก่อนนำไปแปลง json ทำการ sign HMAC-SHA256 โดยใช้ secret "super_secret" ซึ่งต้องรองรับการ config ค่านี้ในอนาคต
|
||||||
|
3. นำค่าที่ sign ได้ มาเทียบกับค่า property "HmacSha256"
|
||||||
|
- ขั้นตอนการทำ authen
|
||||||
|
```
|
||||||
|
ขั้นตอน
|
||||||
|
1. เมื่อเปิดไฟล์ Word ให้อ่าน Custom Property 2 ค่าที่ฝังมาในไฟล์ คือ value จริง และ Hash Value (HMAC) เพื่อตรวจสอบว่าไฟล์สร้างผ่านระบบอย่างถูกต้อง
|
||||||
|
2. ถ้า HMAC ไม่ตรงกัน แสดงว่าไฟล์ถูกแก้ไข ให้ Add-in disable การทำงานทั้งหมด
|
||||||
|
3. ถ้า HMAC ตรงกัน ให้เก็บค่า uId, uName, fId, tid ไว้ แล้วตรวจสอบว่า userId นี้มีใน localStorage อยู่แล้วหรือไม่
|
||||||
|
4. ถ้ายังไม่มีให้ตรวจค่า Exp ที่ได้จากไฟล์ Word
|
||||||
|
- หมดอายุ → disable Add-in ทั้งหมด
|
||||||
|
- ยังไม่หมดอายุ → เก็บใน localStorage
|
||||||
|
- userName: ค่าจาก uName
|
||||||
|
- คีย์คือ fid: เอาค่า {
|
||||||
|
"userid": uid,
|
||||||
|
"username": uname,
|
||||||
|
"fid": fid (เป็น string),
|
||||||
|
"tid": tid
|
||||||
|
}
|
||||||
|
ไปทำการขอ token ที่ /api/documents/token แล้วนำค่าที่ได้มาเก็บไว้ที่ตัวแปรนี้ เพื่อให้อ่านค่าไปใช้ให้ตรงกับค่า fid ที่ได้รับ เนื่องจาก api ในส่วน /documents จะต้องแนบ token ไปไม่ส่งค่าไปตรงๆ เพื่อความถูกต้อง
|
||||||
|
5. ถ้ามี Session อยู่แล้ว ให้ตรวจว่า userId ตรงกับ Session หรือไม่
|
||||||
|
6. ถ้า userId ไม่ตรงกัน (user ใหม่) ให้ตรวจค่า Expire
|
||||||
|
- ยังไม่หมดอายุ → เคลียร์ Session เดิม แล้วสร้าง Session 2 ค่าใหม่
|
||||||
|
- หมดอายุ → disable Add-in ทั้งหมด
|
||||||
|
7. ถ้า userId ตรงกัน (คนเดิม) ให้ตรวจว่า fid มีใน localStorage แล้วหรือไม่
|
||||||
|
- มีแล้ว (กลับมาทำเอกสารเดิม) → ผ่านได้เลย ไม่ต้องตรวจ Expire
|
||||||
|
- ยังไม่มี (เอกสารใหม่) → ตรวจค่า Expire
|
||||||
|
- ยังไม่หมดอายุ → ทำการขอ token แล้วเก็บใน localStorage ตามขั้นตอนก่อนหน้า
|
||||||
|
- หมดอายุ → disable Add-in ทั้งหมด
|
||||||
|
```
|
||||||
29
drizzle/0005_icy_bloodaxe.sql
Normal file
29
drizzle/0005_icy_bloodaxe.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
CREATE TABLE "document_versions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"version_number" integer NOT NULL,
|
||||||
|
"client_version_id" text,
|
||||||
|
"content_hash" text NOT NULL,
|
||||||
|
"message" text,
|
||||||
|
"fields" jsonb,
|
||||||
|
"template_id" text,
|
||||||
|
"file_name" text,
|
||||||
|
"docx_object_key" text NOT NULL,
|
||||||
|
"pdf_object_key" text,
|
||||||
|
"docx_size" integer NOT NULL,
|
||||||
|
"pdf_size" integer,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"user_name" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"doc_id" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
|
||||||
|
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");
|
||||||
735
drizzle/meta/0005_snapshot.json
Normal file
735
drizzle/meta/0005_snapshot.json
Normal file
|
|
@ -0,0 +1,735 @@
|
||||||
|
{
|
||||||
|
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
|
||||||
|
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.annotation_replies": {
|
||||||
|
"name": "annotation_replies",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"annotation_id": {
|
||||||
|
"name": "annotation_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"annotation_replies_annotation_idx": {
|
||||||
|
"name": "annotation_replies_annotation_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "annotation_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"annotation_replies_annotation_id_annotations_id_fk": {
|
||||||
|
"name": "annotation_replies_annotation_id_annotations_id_fk",
|
||||||
|
"tableFrom": "annotation_replies",
|
||||||
|
"tableTo": "annotations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"annotation_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.annotation_versions": {
|
||||||
|
"name": "annotation_versions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"annotation_id": {
|
||||||
|
"name": "annotation_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"file_id": {
|
||||||
|
"name": "file_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"page_index": {
|
||||||
|
"name": "page_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "annotation_type",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"annotation_versions_file_idx": {
|
||||||
|
"name": "annotation_versions_file_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "file_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"annotation_versions_file_id_files_id_fk": {
|
||||||
|
"name": "annotation_versions_file_id_files_id_fk",
|
||||||
|
"tableFrom": "annotation_versions",
|
||||||
|
"tableTo": "files",
|
||||||
|
"columnsFrom": [
|
||||||
|
"file_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.annotations": {
|
||||||
|
"name": "annotations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"file_id": {
|
||||||
|
"name": "file_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"page_index": {
|
||||||
|
"name": "page_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "annotation_type",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"resolved": {
|
||||||
|
"name": "resolved",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"resolved_at": {
|
||||||
|
"name": "resolved_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"annotations_file_page_idx": {
|
||||||
|
"name": "annotations_file_page_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "file_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "page_index",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"annotations_file_id_files_id_fk": {
|
||||||
|
"name": "annotations_file_id_files_id_fk",
|
||||||
|
"tableFrom": "annotations",
|
||||||
|
"tableTo": "files",
|
||||||
|
"columnsFrom": [
|
||||||
|
"file_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.bookmarks": {
|
||||||
|
"name": "bookmarks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"file_id": {
|
||||||
|
"name": "file_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"page_index": {
|
||||||
|
"name": "page_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"name": "parent_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"name": "note",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"y_offset": {
|
||||||
|
"name": "y_offset",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"bookmarks_file_id_files_id_fk": {
|
||||||
|
"name": "bookmarks_file_id_files_id_fk",
|
||||||
|
"tableFrom": "bookmarks",
|
||||||
|
"tableTo": "files",
|
||||||
|
"columnsFrom": [
|
||||||
|
"file_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.document_versions": {
|
||||||
|
"name": "document_versions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"document_id": {
|
||||||
|
"name": "document_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"version_number": {
|
||||||
|
"name": "version_number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"client_version_id": {
|
||||||
|
"name": "client_version_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"content_hash": {
|
||||||
|
"name": "content_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "fields",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"template_id": {
|
||||||
|
"name": "template_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"name": "file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"docx_object_key": {
|
||||||
|
"name": "docx_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pdf_object_key": {
|
||||||
|
"name": "pdf_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"docx_size": {
|
||||||
|
"name": "docx_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pdf_size": {
|
||||||
|
"name": "pdf_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"document_versions_document_number_key": {
|
||||||
|
"name": "document_versions_document_number_key",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "document_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "version_number",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"document_versions_document_created_idx": {
|
||||||
|
"name": "document_versions_document_created_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "document_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"document_versions_document_id_documents_id_fk": {
|
||||||
|
"name": "document_versions_document_id_documents_id_fk",
|
||||||
|
"tableFrom": "document_versions",
|
||||||
|
"tableTo": "documents",
|
||||||
|
"columnsFrom": [
|
||||||
|
"document_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.documents": {
|
||||||
|
"name": "documents",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"doc_id": {
|
||||||
|
"name": "doc_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"documents_doc_id_key": {
|
||||||
|
"name": "documents_doc_id_key",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "doc_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.files": {
|
||||||
|
"name": "files",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pdf_url": {
|
||||||
|
"name": "pdf_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"type_id": {
|
||||||
|
"name": "type_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"files_type_id_idx": {
|
||||||
|
"name": "files_type_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "type_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"files_external_id_key": {
|
||||||
|
"name": "files_external_id_key",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "external_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.annotation_type": {
|
||||||
|
"name": "annotation_type",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"HIGHLIGHT",
|
||||||
|
"DRAW",
|
||||||
|
"COMMENT",
|
||||||
|
"SHAPE",
|
||||||
|
"REDACTION",
|
||||||
|
"TEXTBOX",
|
||||||
|
"STAMP"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,13 @@
|
||||||
"when": 1790072946313,
|
"when": 1790072946313,
|
||||||
"tag": "0004_right_ted_forrester",
|
"tag": "0004_right_ted_forrester",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792467537332,
|
||||||
|
"tag": "0005_icy_bloodaxe",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import type { AnnotateEvent } from "@/lib/annotate-events";
|
||||||
import { withBasePath } from "@/utils/base-path";
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
// SSE sync (PLAN.md §4, rules/realtime.md)
|
// SSE sync (PLAN.md §4, rules/realtime.md)
|
||||||
// - เปิด EventSource ไป /api/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
// - เปิด EventSource ไป /api/note/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||||
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
|
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
|
||||||
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
|
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function useAnnotationRealtime(
|
||||||
const [replies, setReplies] = useState(initialReplies);
|
const [replies, setReplies] = useState(initialReplies);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`));
|
const es = new EventSource(withBasePath(`/api/note/annotations/stream?fileId=${fileId}`));
|
||||||
|
|
||||||
es.onmessage = (e: MessageEvent<string>) => {
|
es.onmessage = (e: MessageEvent<string>) => {
|
||||||
let ev: AnnotateEvent;
|
let ev: AnnotateEvent;
|
||||||
|
|
|
||||||
124
lib/addin-ticket.ts
Normal file
124
lib/addin-ticket.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ
|
||||||
|
// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้
|
||||||
|
// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server
|
||||||
|
// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court
|
||||||
|
// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256
|
||||||
|
// ของกลุ่ม template: hex lowercase บน string ดิบ)
|
||||||
|
//
|
||||||
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน
|
||||||
|
// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts)
|
||||||
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isTicketExpired,
|
||||||
|
normalizeHexDigest,
|
||||||
|
parseDocverTicket,
|
||||||
|
type DocverTicket,
|
||||||
|
} from "@/utils/docver-ticket";
|
||||||
|
import type { DocverTokenClaims } from "@/lib/docver-token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load
|
||||||
|
* ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp
|
||||||
|
* ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret"
|
||||||
|
* (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ
|
||||||
|
* ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ
|
||||||
|
*/
|
||||||
|
export function addinTicketSecret(): string {
|
||||||
|
return (
|
||||||
|
process.env.ADDIN_TICKET_SECRET?.trim() ||
|
||||||
|
process.env.SECRET?.trim() ||
|
||||||
|
"super_secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
|
||||||
|
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
|
||||||
|
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
|
||||||
|
*/
|
||||||
|
export function verifyAddinTicket(
|
||||||
|
ticketData: string,
|
||||||
|
hmacHex: string,
|
||||||
|
secret: string,
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000),
|
||||||
|
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
||||||
|
const digest = normalizeHexDigest(hmacHex);
|
||||||
|
if (digest === null) return { ok: false };
|
||||||
|
|
||||||
|
// เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง
|
||||||
|
// ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw)
|
||||||
|
const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest();
|
||||||
|
const expectedHash = createHash("sha256").update(expected).digest();
|
||||||
|
const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest();
|
||||||
|
if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false };
|
||||||
|
|
||||||
|
const parsed = parseDocverTicket(ticketData);
|
||||||
|
if (!parsed.ok) return { ok: false };
|
||||||
|
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
|
||||||
|
return { ok: true, ticket: parsed.ticket };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
|
||||||
|
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
||||||
|
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */
|
||||||
|
const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const;
|
||||||
|
|
||||||
|
export type TicketTokenBodyResult =
|
||||||
|
| { ok: true; ticketData: string; hmacHex: string }
|
||||||
|
| { ok: false; kind: "missing" | "invalid"; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse body ของ POST /api/documents/token ทางเข้า ticket
|
||||||
|
* - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน)
|
||||||
|
* - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ)
|
||||||
|
*/
|
||||||
|
export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult {
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" };
|
||||||
|
}
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
kind: "invalid",
|
||||||
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => {
|
||||||
|
const value = record[name];
|
||||||
|
if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing
|
||||||
|
if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticketData = read("ticket_data");
|
||||||
|
if (ticketData === null) {
|
||||||
|
return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" };
|
||||||
|
}
|
||||||
|
if (ticketData === "") {
|
||||||
|
return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" };
|
||||||
|
}
|
||||||
|
if (ticketData.length > 2048) {
|
||||||
|
return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacHex = read("HmacSha256");
|
||||||
|
if (hmacHex === null) {
|
||||||
|
return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" };
|
||||||
|
}
|
||||||
|
if (hmacHex === "") {
|
||||||
|
return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" };
|
||||||
|
}
|
||||||
|
if (hmacHex.length > 128) {
|
||||||
|
return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, ticketData, hmacHex };
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
||||||
|
|
||||||
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
|
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
|
||||||
// → SSE route (app/api/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
// → SSE route (app/api/note/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
||||||
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
|
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
|
||||||
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling
|
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling
|
||||||
|
|
||||||
|
|
|
||||||
227
lib/document-versioning.ts
Normal file
227
lib/document-versioning.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
|
||||||
|
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
|
||||||
|
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
|
||||||
|
//
|
||||||
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||||
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
|
||||||
|
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||||
|
|
||||||
|
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
|
||||||
|
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
|
||||||
|
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
|
||||||
|
export const DOCVER_MESSAGE_MAX = 2048;
|
||||||
|
|
||||||
|
export const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||||
|
|
||||||
|
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
|
||||||
|
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
|
||||||
|
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||||
|
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||||
|
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||||
|
const FORM_FIELDS = [
|
||||||
|
"file",
|
||||||
|
"pdf",
|
||||||
|
"clientVersionId",
|
||||||
|
"fields",
|
||||||
|
"message",
|
||||||
|
"fileName",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DocverError = { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
|
||||||
|
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
|
||||||
|
export function docverError(message: string, status: number): Response {
|
||||||
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
|
||||||
|
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
|
||||||
|
export function isDocxBytes(bytes: Buffer): boolean {
|
||||||
|
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfBytes(bytes: Buffer): boolean {
|
||||||
|
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
|
||||||
|
export function parseVersionIdParam(raw: string):
|
||||||
|
| { ok: true; versionId: string }
|
||||||
|
| DocverError {
|
||||||
|
const versionId = raw.trim().toLowerCase();
|
||||||
|
if (!UUID_RE.test(versionId)) {
|
||||||
|
return { ok: false, error: "versionId ต้องเป็น UUID" };
|
||||||
|
}
|
||||||
|
return { ok: true, versionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
|
||||||
|
export function parseDownloadFormat(raw: string | null):
|
||||||
|
| { ok: true; format: "docx" | "pdf" }
|
||||||
|
| DocverError {
|
||||||
|
const format = raw?.trim() ?? "";
|
||||||
|
if (format === "") return { ok: true, format: "docx" };
|
||||||
|
if (format === "docx" || format === "pdf") return { ok: true, format };
|
||||||
|
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimOrNull(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
|
||||||
|
function formString(form: FormData, field: string):
|
||||||
|
| { ok: true; value: string | null }
|
||||||
|
| DocverError {
|
||||||
|
const entry = form.get(field);
|
||||||
|
if (entry === null) return { ok: true, value: null };
|
||||||
|
if (typeof entry !== "string") {
|
||||||
|
return { ok: false, error: `field '${field}' ต้องเป็น string` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimOrNull(entry) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
|
||||||
|
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
|
||||||
|
export function sanitizeDisplayFileName(raw: string | null): string | null {
|
||||||
|
if (raw === null) return null;
|
||||||
|
const base = raw.split(/[\\/]/).pop() ?? raw;
|
||||||
|
return trimOrNull(base)?.slice(0, 200) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
|
||||||
|
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
|
||||||
|
export function sanitizeFilePartName(raw: string): string | null {
|
||||||
|
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||||
|
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||||
|
export function parseFieldsJson(raw: string):
|
||||||
|
| { ok: true; fields: Record<string, string> | null }
|
||||||
|
| DocverError {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||||
|
}
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(record);
|
||||||
|
// เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
|
||||||
|
// เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
|
||||||
|
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
|
||||||
|
if (keys.length > 1_000) {
|
||||||
|
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
|
||||||
|
}
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedVersionUploadForm = {
|
||||||
|
clientVersionId: string | null;
|
||||||
|
message: string | null;
|
||||||
|
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||||
|
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||||
|
fileName: string | null;
|
||||||
|
fields: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||||
|
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
|
||||||
|
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
|
||||||
|
export function parseVersionUploadForm(form: FormData):
|
||||||
|
| { ok: true; form: ParsedVersionUploadForm }
|
||||||
|
| DocverError {
|
||||||
|
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
|
||||||
|
// downlevelIteration — array ธรรมดาไม่ต้อง)
|
||||||
|
for (const key of Array.from(form.keys())) {
|
||||||
|
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientVersionId = formString(form, "clientVersionId");
|
||||||
|
if (!clientVersionId.ok) return clientVersionId;
|
||||||
|
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
||||||
|
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = formString(form, "message");
|
||||||
|
if (!message.ok) return message;
|
||||||
|
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
||||||
|
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFields = formString(form, "fields");
|
||||||
|
if (!rawFields.ok) return rawFields;
|
||||||
|
let fields: Record<string, string> | null = null;
|
||||||
|
if (rawFields.value !== null) {
|
||||||
|
const parsed = parseFieldsJson(rawFields.value);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
fields = parsed.fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFileName = formString(form, "fileName");
|
||||||
|
if (!rawFileName.ok) return rawFileName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
form: {
|
||||||
|
clientVersionId: clientVersionId.value,
|
||||||
|
message: message.value,
|
||||||
|
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
|
||||||
|
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
|
||||||
|
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
|
||||||
|
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
|
||||||
|
|
||||||
|
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
|
||||||
|
const ext = `.${kind}`;
|
||||||
|
const ascii = (displayName ?? "")
|
||||||
|
.replace(/[^\x20-\x7E]/g, "")
|
||||||
|
.replace(/["\\]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
let asciiName: string = DEFAULT_FILE_NAMES[kind];
|
||||||
|
if (/[A-Za-z0-9]/.test(ascii)) {
|
||||||
|
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
|
||||||
|
}
|
||||||
|
const display = displayName?.trim();
|
||||||
|
if (display) {
|
||||||
|
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
|
||||||
|
}
|
||||||
|
return `attachment; filename="${asciiName}"`;
|
||||||
|
}
|
||||||
275
lib/docver-store.ts
Normal file
275
lib/docver-store.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
|
||||||
|
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
|
||||||
|
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
|
||||||
|
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
|
||||||
|
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
|
||||||
|
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
|
||||||
|
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
|
||||||
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
|
import { Client } from "minio";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
documents,
|
||||||
|
documentVersions,
|
||||||
|
type DocumentRow,
|
||||||
|
type DocumentVersionRow,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
contentDispositionValue,
|
||||||
|
DOCX_CONTENT_TYPE,
|
||||||
|
PDF_CONTENT_TYPE,
|
||||||
|
} from "@/lib/document-versioning";
|
||||||
|
|
||||||
|
type MinioConfig = {
|
||||||
|
endPoint: string;
|
||||||
|
port: number;
|
||||||
|
useSSL: boolean;
|
||||||
|
accessKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
bucket: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
|
||||||
|
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
|
||||||
|
function readConfig(): MinioConfig {
|
||||||
|
const endPoint = process.env.MINIO_ENDPOINT?.trim();
|
||||||
|
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
|
||||||
|
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
|
||||||
|
if (!endPoint || !accessKey || !secretKey) {
|
||||||
|
throw new Error(
|
||||||
|
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
|
||||||
|
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
|
||||||
|
return {
|
||||||
|
endPoint,
|
||||||
|
port: Number.isFinite(port) && port > 0 ? port : 9000,
|
||||||
|
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
|
||||||
|
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
|
||||||
|
ttlSeconds:
|
||||||
|
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
|
||||||
|
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
|
||||||
|
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
|
||||||
|
const globalForMinio = globalThis as unknown as {
|
||||||
|
docverClient?: { key: string; client: Client };
|
||||||
|
docverBucketReady?: { bucket: string; promise: Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClient(): { client: Client; config: MinioConfig } {
|
||||||
|
const config = readConfig();
|
||||||
|
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
|
||||||
|
if (globalForMinio.docverClient?.key !== key) {
|
||||||
|
globalForMinio.docverClient = {
|
||||||
|
key,
|
||||||
|
client: new Client({
|
||||||
|
endPoint: config.endPoint,
|
||||||
|
port: config.port,
|
||||||
|
useSSL: config.useSSL,
|
||||||
|
accessKey: config.accessKey,
|
||||||
|
secretKey: config.secretKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { client: globalForMinio.docverClient.client, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
|
||||||
|
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
|
||||||
|
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
|
||||||
|
async function ensureBucket(client: Client, bucket: string): Promise<void> {
|
||||||
|
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
|
||||||
|
const promise = (async () => {
|
||||||
|
if (await client.bucketExists(bucket)) return;
|
||||||
|
try {
|
||||||
|
await client.makeBucket(bucket);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(await client.bucketExists(bucket))) throw err;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
globalForMinio.docverBucketReady = { bucket, promise };
|
||||||
|
promise.catch(() => {
|
||||||
|
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
|
||||||
|
if (globalForMinio.docverBucketReady?.promise === promise) {
|
||||||
|
globalForMinio.docverBucketReady = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return globalForMinio.docverBucketReady.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
|
||||||
|
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
|
||||||
|
return {
|
||||||
|
docx: `docver/${versionId}/document.docx`,
|
||||||
|
pdf: `docver/${versionId}/document.pdf`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PutVersionInput = {
|
||||||
|
versionId: string;
|
||||||
|
docx: { bytes: Buffer; fileName: string | null };
|
||||||
|
pdf?: { bytes: Buffer; fileName: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
|
||||||
|
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
|
||||||
|
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
|
||||||
|
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
|
||||||
|
const { client, config } = getClient();
|
||||||
|
await ensureBucket(client, config.bucket);
|
||||||
|
const keys = versionObjectKeys(input.versionId);
|
||||||
|
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
|
||||||
|
"Content-Type": DOCX_CONTENT_TYPE,
|
||||||
|
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
|
||||||
|
});
|
||||||
|
if (!input.pdf) return;
|
||||||
|
try {
|
||||||
|
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
|
||||||
|
"Content-Type": PDF_CONTENT_TYPE,
|
||||||
|
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
|
||||||
|
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
|
||||||
|
export async function removeVersionObjects(
|
||||||
|
versionId: string,
|
||||||
|
hadPdf: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { client, config } = getClient();
|
||||||
|
const keys = versionObjectKeys(versionId);
|
||||||
|
await client.removeObject(config.bucket, keys.docx);
|
||||||
|
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
|
||||||
|
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
|
||||||
|
* คำนวณ local ล้วนไม่มี network call */
|
||||||
|
export async function presignVersionUrl(
|
||||||
|
versionId: string,
|
||||||
|
format: "docx" | "pdf",
|
||||||
|
): Promise<{ url: string; expiresAt: string }> {
|
||||||
|
const { client, config } = getClient();
|
||||||
|
const key = versionObjectKeys(versionId)[format];
|
||||||
|
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DB ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
|
||||||
|
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
|
||||||
|
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(documentVersions)
|
||||||
|
.where(eq(documentVersions.documentId, documentId))
|
||||||
|
.orderBy(desc(documentVersions.versionNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
|
||||||
|
export async function findVersionForDoc(
|
||||||
|
docId: string,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<DocumentVersionRow | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ version: documentVersions })
|
||||||
|
.from(documentVersions)
|
||||||
|
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
|
||||||
|
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
|
||||||
|
.limit(1);
|
||||||
|
return row?.version ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InsertVersionInput = {
|
||||||
|
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
|
||||||
|
versionId: string;
|
||||||
|
docId: string;
|
||||||
|
clientVersionId: string | null;
|
||||||
|
contentHash: string;
|
||||||
|
message: string | null;
|
||||||
|
fields: Record<string, string> | null;
|
||||||
|
templateId: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
docxObjectKey: string;
|
||||||
|
pdfObjectKey: string | null;
|
||||||
|
docxSize: number;
|
||||||
|
pdfSize: number | null;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||||
|
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
|
||||||
|
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
|
||||||
|
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
|
||||||
|
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.insert(documents)
|
||||||
|
.values({ docId: input.docId })
|
||||||
|
.onConflictDoNothing({ target: documents.docId });
|
||||||
|
|
||||||
|
const [doc] = await tx
|
||||||
|
.select({ id: documents.id })
|
||||||
|
.from(documents)
|
||||||
|
.where(eq(documents.docId, input.docId))
|
||||||
|
.for("update");
|
||||||
|
if (!doc) {
|
||||||
|
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
|
||||||
|
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [latest] = await tx
|
||||||
|
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
|
||||||
|
.from(documentVersions)
|
||||||
|
.where(eq(documentVersions.documentId, doc.id));
|
||||||
|
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(documentVersions)
|
||||||
|
.values({
|
||||||
|
id: input.versionId,
|
||||||
|
documentId: doc.id,
|
||||||
|
versionNumber: (latest?.max ?? 0) + 1,
|
||||||
|
clientVersionId: input.clientVersionId,
|
||||||
|
contentHash: input.contentHash,
|
||||||
|
message: input.message,
|
||||||
|
fields: input.fields,
|
||||||
|
templateId: input.templateId,
|
||||||
|
fileName: input.fileName,
|
||||||
|
docxObjectKey: input.docxObjectKey,
|
||||||
|
pdfObjectKey: input.pdfObjectKey,
|
||||||
|
docxSize: input.docxSize,
|
||||||
|
pdfSize: input.pdfSize,
|
||||||
|
userId: input.userId,
|
||||||
|
userName: input.userName,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
});
|
||||||
|
}
|
||||||
223
lib/docver-token.ts
Normal file
223
lib/docver-token.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
||||||
|
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
||||||
|
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
||||||
|
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
||||||
|
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
||||||
|
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
||||||
|
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
||||||
|
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
||||||
|
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
|
||||||
|
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
|
||||||
|
//
|
||||||
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||||
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
||||||
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import { docverError, type DocverError } from "@/lib/document-versioning";
|
||||||
|
|
||||||
|
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
||||||
|
export type DocverTokenClaims = {
|
||||||
|
userid: string;
|
||||||
|
username: string;
|
||||||
|
fid: string;
|
||||||
|
template: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function base64url(input: string): string {
|
||||||
|
return Buffer.from(input, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = Buffer.from(segment, "base64url").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (
|
||||||
|
parsed === null ||
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
|
||||||
|
export function issueDocverToken(
|
||||||
|
claims: DocverTokenClaims,
|
||||||
|
secret: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
): string {
|
||||||
|
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = base64url(
|
||||||
|
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
|
||||||
|
);
|
||||||
|
const signature = createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
||||||
|
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
||||||
|
function claimsFromPayload(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): DocverTokenClaims | null {
|
||||||
|
const { userid, username, fid, template } = payload;
|
||||||
|
if (typeof userid !== "string" || userid === "") return null;
|
||||||
|
if (typeof username !== "string" || username === "") return null;
|
||||||
|
if (typeof fid !== "string" || fid === "") return null;
|
||||||
|
if (template !== null && typeof template !== "string") return null;
|
||||||
|
return { userid, username, fid, template };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
|
||||||
|
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
|
||||||
|
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
|
||||||
|
export function verifyDocverToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
|
||||||
|
const segments = token.split(".");
|
||||||
|
if (segments.length !== 3) return { ok: false };
|
||||||
|
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
||||||
|
|
||||||
|
const header = decodeJsonSegment(headerSegment);
|
||||||
|
if (header === null || header.alg !== "HS256") return { ok: false };
|
||||||
|
|
||||||
|
const expected = createHmac("sha256", secret)
|
||||||
|
.update(`${headerSegment}.${payloadSegment}`)
|
||||||
|
.digest();
|
||||||
|
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
||||||
|
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
||||||
|
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
||||||
|
const provided = createHash("sha256")
|
||||||
|
.update(Buffer.from(signatureSegment, "base64url"))
|
||||||
|
.digest();
|
||||||
|
const expectedHash = createHash("sha256").update(expected).digest();
|
||||||
|
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
|
||||||
|
|
||||||
|
const payload = decodeJsonSegment(payloadSegment);
|
||||||
|
if (payload === null) return { ok: false };
|
||||||
|
const exp = payload.exp;
|
||||||
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
||||||
|
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
||||||
|
|
||||||
|
const claims = claimsFromPayload(payload);
|
||||||
|
if (claims === null) return { ok: false };
|
||||||
|
return { ok: true, claims };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
|
||||||
|
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
|
||||||
|
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||||
|
export function requireDocverApiKey(request: Request): Response | null {
|
||||||
|
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||||
|
const envKey = process.env.DOCVER_API_KEY?.trim();
|
||||||
|
// const envKey =
|
||||||
|
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
||||||
|
if (envKey === undefined || envKey === "") {
|
||||||
|
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
||||||
|
}
|
||||||
|
const provided = request.headers.get("x-api-key");
|
||||||
|
if (provided === null || provided === "") {
|
||||||
|
return docverError("API key ไม่ถูกต้อง", 401);
|
||||||
|
}
|
||||||
|
const providedHash = createHash("sha256").update(provided).digest();
|
||||||
|
const envHash = createHash("sha256").update(envKey).digest();
|
||||||
|
return timingSafeEqual(providedHash, envHash)
|
||||||
|
? null
|
||||||
|
: docverError("API key ไม่ถูกต้อง", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
||||||
|
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
||||||
|
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
||||||
|
|
||||||
|
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
||||||
|
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
||||||
|
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
||||||
|
export function requireDocverBearer(
|
||||||
|
request: Request,
|
||||||
|
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
||||||
|
const denied = () =>
|
||||||
|
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
||||||
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
|
// const secret =
|
||||||
|
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||||
|
if (secret === undefined || secret === "") return denied();
|
||||||
|
const header = request.headers.get("authorization");
|
||||||
|
if (header === null) return denied();
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||||
|
if (match === null) return denied();
|
||||||
|
const verified = verifyDocverToken(match[1], secret);
|
||||||
|
if (!verified.ok) return denied();
|
||||||
|
return { ok: true, claims: verified.claims };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
||||||
|
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
||||||
|
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
||||||
|
|
||||||
|
export function parseTokenRequestBody(
|
||||||
|
raw: unknown,
|
||||||
|
): { ok: true; claims: DocverTokenClaims } | DocverError {
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return { ok: false, error: "body ต้องเป็น JSON object" };
|
||||||
|
}
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUserid = record.userid;
|
||||||
|
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
|
||||||
|
return { ok: false, error: "ต้องระบุ userid (string)" };
|
||||||
|
}
|
||||||
|
const userid = rawUserid.trim();
|
||||||
|
if (userid.length > 100) {
|
||||||
|
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUsername = record.username;
|
||||||
|
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
|
||||||
|
return { ok: false, error: "ต้องระบุ username (string)" };
|
||||||
|
}
|
||||||
|
const username = rawUsername.trim();
|
||||||
|
if (username.length > 200) {
|
||||||
|
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFid = record.fid;
|
||||||
|
if (typeof rawFid !== "string" || rawFid.trim() === "") {
|
||||||
|
return { ok: false, error: "ต้องระบุ fid (string)" };
|
||||||
|
}
|
||||||
|
const fid = rawFid.trim();
|
||||||
|
if (fid.length > 200) {
|
||||||
|
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTemplate = record.template ?? null;
|
||||||
|
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
||||||
|
return { ok: false, error: "template ต้องเป็น string" };
|
||||||
|
}
|
||||||
|
const template = rawTemplate?.trim() || null;
|
||||||
|
if (template !== null && template.length > 100) {
|
||||||
|
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, claims: { userid, username, fid, template } };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
|
// N10 (PLAN-API.md D1) — auth ของ /api/note/external/* (ผู้เรียก: backend e-Court,
|
||||||
// server-to-server ไม่มี browser)
|
// server-to-server ไม่มี browser)
|
||||||
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
|
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
|
||||||
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
|
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/external/documents
|
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
|
||||||
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
|
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
|
||||||
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
|
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
|
||||||
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
|
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
|
||||||
|
|
@ -81,7 +81,7 @@ function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validate JSON body ของ POST /api/external/documents (PLAN-API.md D2) — strict กัน
|
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
|
||||||
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
|
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
|
||||||
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
|
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
|
||||||
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
|
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
|
||||||
|
|
|
||||||
1314
lib/lib-tmpl.ts
Executable file
1314
lib/lib-tmpl.ts
Executable file
File diff suppressed because it is too large
Load diff
112
lib/lib-utils.ts
Normal file
112
lib/lib-utils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
|
||||||
|
import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from 'node:crypto';
|
||||||
|
/**
|
||||||
|
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
|
||||||
|
*/
|
||||||
|
export function generateHmacSha256(data: string | object, secretKey: string): string {
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
return createHmac('sha256', secretKey)
|
||||||
|
.update(payload, 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
|
||||||
|
*/
|
||||||
|
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
|
||||||
|
const calculatedHmac = generateHmacSha256(data, secretKey);
|
||||||
|
const a = Buffer.from(calculatedHmac, 'hex');
|
||||||
|
const b = Buffer.from(expectedHmac, 'hex');
|
||||||
|
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
// ==========================================
|
||||||
|
// ตัวอย่างการใช้งาน
|
||||||
|
// ==========================================
|
||||||
|
/*
|
||||||
|
const secret = 'my-super-secret-key';
|
||||||
|
const jsonData = {
|
||||||
|
userId: 12345,
|
||||||
|
action: 'transfer',
|
||||||
|
amount: 500
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. สร้าง HMAC
|
||||||
|
const hmacSignature = generateHmacSha256(jsonData, secret);
|
||||||
|
console.log('HMAC Signature (Hex):', hmacSignature);
|
||||||
|
|
||||||
|
// 2. ตรวจสอบ HMAC
|
||||||
|
const isValid = verifyHmacSha256(jsonData, secret, hmacSignature);
|
||||||
|
console.log('Is Signature Valid?:', isValid); // true
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign) ข้อมูลด้วย Ed25519 Private Key (Hex)
|
||||||
|
* @param message ข้อความดิบที่ต้องการเซ็น (string หรือ Buffer)
|
||||||
|
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||||
|
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
|
||||||
|
*/
|
||||||
|
export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
|
||||||
|
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
|
||||||
|
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
|
||||||
|
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
|
||||||
|
// PKCS#8 Header สำหรับ Ed25519 Private Key
|
||||||
|
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||||
|
const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]);
|
||||||
|
|
||||||
|
const privateKey = createPrivateKey({
|
||||||
|
key: pkcs8Buffer,
|
||||||
|
format: 'der',
|
||||||
|
type: 'pkcs8',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||||
|
const signatureBuffer = sign(null, dataBuffer, privateKey);
|
||||||
|
return signatureBuffer.toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
|
||||||
|
* @param message ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
|
||||||
|
* @param signatureHex ลายเซ็นในรูปแบบ Hex String
|
||||||
|
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||||
|
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
||||||
|
*/
|
||||||
|
export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
|
||||||
|
try {
|
||||||
|
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
|
||||||
|
|
||||||
|
// SPKI Header สำหรับ Ed25519 Public Key (32 bytes)
|
||||||
|
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
|
||||||
|
const spkiBuffer = Buffer.concat([spkiHeader, rawPublicKey]);
|
||||||
|
|
||||||
|
const publicKey = createPublicKey({
|
||||||
|
key: spkiBuffer,
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||||
|
const signatureBuffer = Buffer.from(signatureHex, 'hex');
|
||||||
|
|
||||||
|
return verify(null, dataBuffer, publicKey, signatureBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ฟังก์ชันสำหรับเรียงคีย์ของ Object ตามตัวอักษรเพื่อไม่ให้โครงสร้าง JSON เพี้ยน
|
||||||
|
*/
|
||||||
|
export function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.keys(obj)
|
||||||
|
.sort()
|
||||||
|
.reduce((result: Record<string, unknown>, key: string) => {
|
||||||
|
result[key] = obj[key];
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
1181
lib/openapi.ts
Normal file
1181
lib/openapi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
|
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
|
||||||
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
|
// ใช้ 2 จุด: /api/note/pdf (viewer) และ /api/note/annotations/export — กัน CORS ฝั่ง browser
|
||||||
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
|
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
|
||||||
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
|
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import path from "node:path";
|
||||||
export class PdfPathError extends Error {}
|
export class PdfPathError extends Error {}
|
||||||
|
|
||||||
export function resolvePublicPdfPath(pdfUrl: string): string {
|
export function resolvePublicPdfPath(pdfUrl: string): string {
|
||||||
const publicDir = path.resolve(process.cwd(), "public");
|
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
|
||||||
|
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
|
||||||
|
const publicDir = path.resolve(process.cwd(), "public", "note");
|
||||||
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
|
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
|
||||||
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
|
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
|
||||||
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));
|
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ export function middleware(req: NextRequest) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
|
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
|
||||||
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /login มี query เกินจำเป็น
|
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /note/login มี query เกินจำเป็น
|
||||||
// (path ตรงนี้ตัด basePath แล้ว / NextURL ใส่ basePath คืนตอน redirect เอง — invariant #12)
|
// (path ตรงนี้ตัด basePath แล้ว จึงขึ้นต้น /note/… เสมอ / NextURL ใส่ basePath คืนตอน
|
||||||
|
// redirect เอง — invariant #12)
|
||||||
const next = url.pathname + url.search;
|
const next = url.pathname + url.search;
|
||||||
url.pathname = "/login";
|
url.pathname = "/note/login";
|
||||||
url.search = "";
|
url.search = "";
|
||||||
if (next !== "/") url.searchParams.set("next", next);
|
if (next !== "/") url.searchParams.set("next", next);
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
|
|
@ -20,10 +21,22 @@ export function middleware(req: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
|
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||||
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
|
// (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 จับตามปกติ
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
|
"/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,38 @@
|
||||||
const basePath = "/docedit/note";
|
const basePath = "/docedit";
|
||||||
|
// prefix ระดับแอปของ judes-note ใน route tree (app/note/**) + statics (public/note/**)
|
||||||
|
// — basePath /docedit เป็นของกลางแชร์กับแอปพี่น้อง (เช่น wordaddin ในอนาคต)
|
||||||
|
// URL ปลายทางจึงยังเป็น /docedit/note/… เหมือนเดิมทุกจุด
|
||||||
|
const appPath = "/note";
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
|
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
// serve ใต้ /docedit/note — <Link>/next/navigation เติมให้เอง แต่ fetch/EventSource/
|
// serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath
|
||||||
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts)
|
// ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/login") ส่วน fetch/EventSource/
|
||||||
|
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note)
|
||||||
basePath,
|
basePath,
|
||||||
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
|
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
|
||||||
env: { NEXT_PUBLIC_BASE_PATH: basePath },
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
|
|
|
||||||
8402
package-lock.json
generated
8402
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -8,29 +8,40 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||||
"postinstall": "node scripts/patch-pdf-worker.mjs"
|
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
|
||||||
|
"dev:https": "next dev --experimental-https",
|
||||||
|
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
|
"@xmldom/xmldom": "^0.9.12",
|
||||||
|
"docx-templates": "^4.15.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.42.0",
|
"lucide-react": "^1.42.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"pizzip": "^3.3.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"utif2": "^4.1.0"
|
"swagger-ui-dist": "^5.33.1",
|
||||||
|
"utif2": "^4.1.0",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/office-js": "^1.0.612",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/react": "^18.3.31",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"autoprefixer": "^10.5.5",
|
"autoprefixer": "^10.5.5",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"office-addin-debugging": "^7.0.1",
|
||||||
|
"office-addin-dev-settings": "^4.0.1",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|
|
||||||
7543
pnpm-lock.yaml
generated
Normal file
7543
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
allowBuilds:
|
||||||
|
'@azure/msal-node-extensions': set this to true or false
|
||||||
|
'@microsoft/m365agentstoolkit-cli': set this to true or false
|
||||||
|
'@scarf/scarf': set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
|
keytar: set this to true or false
|
||||||
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
public/note/swagger-ui/swagger-ui.css
Normal file
3
public/note/swagger-ui/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
44
public/tmpl/T12_FD155_3s-long.json
Normal file
44
public/tmpl/T12_FD155_3s-long.json
Normal file
File diff suppressed because one or more lines are too long
BIN
public/tmpl/T12_X.docx
Executable file
BIN
public/tmpl/T12_X.docx
Executable file
Binary file not shown.
41
public/tmpl/T12_b2_IN.json
Normal file
41
public/tmpl/T12_b2_IN.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||||
|
},
|
||||||
|
"dc": {
|
||||||
|
"Interpleader": true,
|
||||||
|
"docname": "",
|
||||||
|
"docabbr": "(ต. ๑๒)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"redno": "คดีหมายเลขแดงที่",
|
||||||
|
"court": "ศาลปกครองนครราชสีมา",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"pname": "นายวิรุฬห์ จินตนะกุล",
|
||||||
|
"pmname": "ตุลาการศาลปกครองกลาง",
|
||||||
|
"pm": "ตุลาการเจ้าของสำนวน"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pname": "นายสัมฤทธิ์ อ่อนคำ",
|
||||||
|
"pmname": "ตุลาการศาลปกครองสูงสุด"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pnameT":"",
|
||||||
|
"pmnameT":"",
|
||||||
|
"pmT":"",
|
||||||
|
"acc_name":""
|
||||||
|
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"blackyear": "๙๐/๒๕๖๙",
|
||||||
|
"redyear": "",
|
||||||
|
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
|
||||||
|
"accuser": "นายธีรพล ร่มเริง",
|
||||||
|
"acc1": "ผู้ฟ้องคดี",
|
||||||
|
"accused": "รร",
|
||||||
|
"acc2": "ผู้ถูกฟ้องคดี",
|
||||||
|
"interpleader": "สมชาย ใจดี",
|
||||||
|
"acc3": "ผู้ร้องสอด",
|
||||||
|
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
public/tmpl/TF_no_warning_P2.json
Executable file
50
public/tmpl/TF_no_warning_P2.json
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||||
|
},
|
||||||
|
"dc": {
|
||||||
|
"Interpleader": false,
|
||||||
|
"CommandTo": true,
|
||||||
|
"SendTo": false,
|
||||||
|
"AdditionCommand" : true,
|
||||||
|
"Warning":false,
|
||||||
|
|
||||||
|
"docname":"คำสั่งศาล",
|
||||||
|
"doctype":"(ต. ๔๗.๑)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"redno": "คดีหมายเลขแดงที่",
|
||||||
|
|
||||||
|
"court": "ศาลปกครองนครราชสีมา",
|
||||||
|
"doctitle":"คำสั่งศาลถึง",
|
||||||
|
"pname":"สมหญิง",
|
||||||
|
|
||||||
|
"courtname":"ศาลปกครองสุพรรณบุรี",
|
||||||
|
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||||
|
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||||
|
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||||
|
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
|
||||||
|
"pname2":"ชื่อของ pname2"
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"blackyear": "๙๐/๒๕๖๙",
|
||||||
|
"redyear": "xxx",
|
||||||
|
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||||
|
"commto" : "นายสมชาย จรดปลายเท้า",
|
||||||
|
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
|
||||||
|
"groupname":"ผู้อำนวยการโรงเรียน",
|
||||||
|
|
||||||
|
"accuser": "นายธีรพล ร่มเริง",
|
||||||
|
"acc1": "ผู้ฟ้องคดี",
|
||||||
|
"accused": "รร",
|
||||||
|
"acc2": "ผู้ถูกฟ้องคดี",
|
||||||
|
"interpleader": "สมชาย ใจดี",
|
||||||
|
"acc3": "ผู้ร้องสอด",
|
||||||
|
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||||
|
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||||
|
|
||||||
|
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||||
|
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
|
||||||
|
"remarkhead":"หัวของ Remark",
|
||||||
|
"remark":"This is remark Text bla bla "
|
||||||
|
}
|
||||||
|
}
|
||||||
52
public/tmpl/TS_P2_IN.json
Executable file
52
public/tmpl/TS_P2_IN.json
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
||||||
|
},
|
||||||
|
"dc": {
|
||||||
|
"Interpleader": true,
|
||||||
|
"CommandTo": true,
|
||||||
|
"SendTo": false,
|
||||||
|
"AdditionCommand" : true,
|
||||||
|
"Warning":true,
|
||||||
|
|
||||||
|
"docname":"คำสั่งเรียกพยานบุคคล",
|
||||||
|
"doctype":"(ต. ๖)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"redno": "คดีหมายเลขแดงที่",
|
||||||
|
|
||||||
|
"court": "ศาลปกครองนครราชสีมา",
|
||||||
|
"doctitle":"คำสั่งถึง",
|
||||||
|
"pname":"สมหญิง",
|
||||||
|
|
||||||
|
"courtname":"ศาลปกครองสุพรรณบุรี",
|
||||||
|
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||||
|
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||||
|
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||||
|
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
|
||||||
|
|
||||||
|
"pname2":"ชื่อของ pname2",
|
||||||
|
"remarkhead":"หัวของ Remark"
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"blackyear": "๙๐/๒๕๖๙",
|
||||||
|
"redyear": "xxx",
|
||||||
|
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
|
||||||
|
"commto" : "นายสมชาย จรดปลายเท้า",
|
||||||
|
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
|
||||||
|
"groupname":"ผู้อำนวยการโรงเรียน",
|
||||||
|
|
||||||
|
"accuser": "นายธีรพล ร่มเริง",
|
||||||
|
"acc1": "ผู้ฟ้องคดี",
|
||||||
|
"accused": "รร",
|
||||||
|
"acc2": "ผู้ถูกฟ้องคดี",
|
||||||
|
"interpleader": "สมชาย ใจดี",
|
||||||
|
"acc3": "ผู้ร้องสอด",
|
||||||
|
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||||
|
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||||
|
|
||||||
|
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||||
|
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
|
||||||
|
|
||||||
|
"remark":"This is remark Text bla bla "
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/tmpl/T_X.docx
Executable file
BIN
public/tmpl/T_X.docx
Executable file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue