ecourt+groups: N8 เปิดเอกสารจากลิงก์ ?fileUrl=&fileId=&typeId= (metadata-only + allowlist + ดึงไฟล์สด + login วนกลับ + การ์ดจำลองลิงก์ + path dev /samples) + N9 กลุ่มเอกสารตาม typeId — หน้ารายการจัด section ต่อกลุ่ม + "ไม่มีกลุ่ม" ท้ายสุด + หน้า /groups/[typeId] พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" + docs sync ครบ (2026-09-23)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
0aef3ff028
commit
329ae8e956
31 changed files with 2766 additions and 155 deletions
|
|
@ -21,3 +21,4 @@ paths:
|
|||
- ห้ามเพิ่มตาราง/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 หน้า)
|
||||
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
- `files.type_id` (N9 — รหัสกลุ่มเอกสารจากลิงก์ e-Court `?typeId=`): **เขียนที่ `registerExternalFile` ของ N8 เท่านั้น** (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
# PostgreSQL container judes-note-db (สร้างด้วย docker run ตาม README)
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||
# N8 (optional): allowlist โฮสต์ลิงก์ไฟล์รีโมต — ไม่ตั้ง = admincourt.go.th
|
||||
# EXTERNAL_FILE_HOSTS=admincourt.go.th
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -20,7 +20,7 @@
|
|||
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/` และ `/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`)
|
||||
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
||||
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
||||
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
||||
|
|
@ -39,12 +39,15 @@ middleware.ts ← บังคับ login ทุกหน้า — ไ
|
|||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้)
|
||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||
lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า / และ /annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน)
|
||||
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/pdf กับ export ใช้ร่วม)
|
||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok)
|
||||
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
|
||||
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
|
||||
utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl (pure)
|
||||
utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12)
|
||||
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
|
||||
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
|
||||
|
|
@ -59,7 +62,8 @@ app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pd
|
|||
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
|
||||
app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17)
|
||||
app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||
app/page.tsx ← รายการไฟล์ (server component)
|
||||
app/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
||||
app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404, หมายเหตุ "อาจไม่ครบทุกฉบับ")
|
||||
app/login/page.tsx ← หน้า login (มี session แล้ว redirect /)
|
||||
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||
app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark จัดกลุ่มตามหน้า (design "แฟ้มสำนวน" 2026-09-17: แถบหัวน้ำเงิน + รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด ขอบ folio — คำขอผู้ใช้ 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น; ต่อหน้า = บรรทัดสรุปหน้า (ไม่มีหัวข้อ) + Bookmark/Annotation หุบ-กางได้ผ่าน CollapsibleBlock; รายการตามหน้า desktop = การ์ด sticky มี max-h, มือถือ < lg = <details> การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/รายการตามหน้ามือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
||||
|
|
@ -71,13 +75,14 @@ components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles
|
|||
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
|
||||
components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป)
|
||||
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4)
|
||||
components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /?fileUrl=&fileId=&typeId= ให้หน้า / ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court)
|
||||
components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/"))
|
||||
components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม)
|
||||
```
|
||||
|
||||
## ขอบเขต — ห้ามทำเกิน (Out of Scope)
|
||||
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม)
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" (อ่าน `type_id` อย่างเดียว — เขียนที่ N8 `registerExternalFile` เท่านั้น ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`)
|
||||
|
||||
- ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
|
||||
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
|
||||
|
|
|
|||
63
app/annotate/page.tsx
Normal file
63
app/annotate/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /?fileUrl= และ /annotate?fileUrl=)
|
||||
*/
|
||||
export default async function AnnotateEntryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-slate-100 px-1">
|
||||
/annotate?fileUrl=…&fileId=…&typeId=…
|
||||
</code>{" "}
|
||||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
|||
|
||||
import { db } from "@/db";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
|
|
@ -219,14 +220,24 @@ export async function GET(req: Request) {
|
|||
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
|
||||
|
||||
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
let srcBytes: Buffer;
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) {
|
||||
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) {
|
||||
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
|
||||
}
|
||||
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
|
||||
}
|
||||
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
|
||||
}
|
||||
|
||||
// N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
|||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind";
|
||||
|
||||
|
|
@ -25,12 +26,22 @@ export async function GET(req: Request) {
|
|||
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
|
||||
|
||||
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
let srcBytes: Buffer;
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
|
||||
throw err;
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// N3: ไฟล์รูปภาพไม่มี ToUnicode ให้ซ่อม — คืน bytes ดิบ + content-type ถูกต้อง
|
||||
|
|
|
|||
120
app/groups/[typeId]/page.tsx
Normal file
120
app/groups/[typeId]/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||
import { ArrowLeft, FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { annotations, bookmarks, files } from "@/db/schema";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
|
||||
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
||||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
||||
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { typeId: string };
|
||||
}): Promise<Metadata> {
|
||||
return { title: `กลุ่มเอกสาร ${params.typeId} | Judge's Note` };
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params,
|
||||
}: {
|
||||
params: { typeId: string };
|
||||
}) {
|
||||
const { typeId } = params;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.typeId, typeId))
|
||||
.orderBy(asc(files.createdAt));
|
||||
if (rows.length === 0) notFound();
|
||||
|
||||
// จำนวน annotation/bookmark ต่อไฟล์ — 2 query แยกด้วย groupBy fileId (ไม่ join หนัก)
|
||||
const ids = rows.map((f) => f.id);
|
||||
const [annCounts, bmCounts] = await Promise.all([
|
||||
db
|
||||
.select({ fileId: annotations.fileId, n: count() })
|
||||
.from(annotations)
|
||||
.where(inArray(annotations.fileId, ids))
|
||||
.groupBy(annotations.fileId),
|
||||
db
|
||||
.select({ fileId: bookmarks.fileId, n: count() })
|
||||
.from(bookmarks)
|
||||
.where(inArray(bookmarks.fileId, ids))
|
||||
.groupBy(bookmarks.fileId),
|
||||
]);
|
||||
// drizzle count() ใช้ .mapWith(Number) ให้แล้ว — r.n เป็น number ทั้ง type และ runtime
|
||||
const annByFile = new Map(annCounts.map((r) => [r.fileId, r.n]));
|
||||
const bmByFile = new Map(bmCounts.map((r) => [r.fileId, r.n]));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition hover:text-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Folder className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="break-all text-xl font-bold leading-tight text-primary">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">{rows.length} เอกสาร</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{rows.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · หมายเหตุ{" "}
|
||||
{annByFile.get(f.id) ?? 0} · ที่คั่นหน้า {bmByFile.get(f.id) ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,19 +3,29 @@ import { redirect } from "next/navigation";
|
|||
|
||||
import LoginForm from "@/components/LoginForm";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { firstSearchParam, sanitizeNextPath } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" };
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// N8 D5: path วนกลับหลัง login (middleware แนบมาตอน redirect เข้าหน้านี้) —
|
||||
// ค่ามาจาก query ที่ใครก็แก้ได้ ต้อง sanitize กัน open-redirect ก่อนใช้เสมอ
|
||||
const rawNext = firstSearchParam(searchParams.next);
|
||||
const next = rawNext !== undefined ? sanitizeNextPath(rawNext) : null;
|
||||
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect("/");
|
||||
if (user) redirect(next ?? "/");
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<LoginForm />
|
||||
<LoginForm next={next} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
168
app/page.tsx
168
app/page.tsx
|
|
@ -1,18 +1,23 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText } from "lucide-react";
|
||||
import { FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
||||
import SessionBar from "@/components/SessionBar";
|
||||
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)) {
|
||||
|
|
@ -27,13 +32,86 @@ function kindBadge(pdfUrl: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
/** การ์ดไฟล์ของหน้ารายการ — ใช้ร่วมทุก section (N9 จัดกลุ่มแล้วยังหน้าตาเดิม) */
|
||||
function FileCard({ file }: { file: FileRow }) {
|
||||
return (
|
||||
<li className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{file.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /annotate?fileUrl=… — app/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
}
|
||||
|
||||
const 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 (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<header className="flex items-center gap-3">
|
||||
|
|
@ -52,6 +130,18 @@ export default async function HomePage() {
|
|||
|
||||
<SessionBar userName={user.userName} />
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดเปิดเอกสารจากลิงก์ e-Court — บล็อกแยกของตัวเอง มีหัวเรื่องในตัวแล้ว (N8) */}
|
||||
<EcourtLinkForm />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
|
||||
<UploadFileForm />
|
||||
|
|
@ -75,40 +165,50 @@ export default async function HomePage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{rows.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-6">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 break-all text-sm font-semibold text-slate-700">
|
||||
{typeId}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-slate-500">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(typeId)}`}
|
||||
className="ml-auto shrink-0 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||
{kindBadge(f.pdfUrl)}
|
||||
</span>
|
||||
{formatThaiDate(f.createdAt, true)}
|
||||
</span>
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</li>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
ไม่มีกลุ่ม
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-slate-500">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
131
components/EcourtLinkForm.tsx
Normal file
131
components/EcourtLinkForm.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
// N8 — การ์ดจำลองลิงก์ e-Court บนหน้ารายการ: กรอก fileUrl (+ fileId + typeId) แล้วกด
|
||||
// "เปิดเอกสาร" จะพาไป /annotate?fileUrl=&fileId=&typeId= ซึ่งลงทะเบียนเอกสาร
|
||||
// (metadata-only) แล้ว redirect เข้าหน้า viewer ให้เอง — ใช้ทดสอบในเครื่องก่อนมี
|
||||
// ลิงก์จริงจาก e-Court (รับทั้ง path ในระบบเช่น /samples/… และ URL ของโฮสต์ที่อนุญาต
|
||||
// — ไม่มี scheme นำหน้าระบบเติม https:// ให้)
|
||||
// แถบลิงก์เต็มข้างล่างแสดง URL จริงที่ปุ่มจะพาไป (absolute — copy ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
const inputClass =
|
||||
"rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 placeholder:text-slate-400 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
|
||||
|
||||
export default function EcourtLinkForm() {
|
||||
const router = useRouter();
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [fileId, setFileId] = useState("");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// origin เอาตอน client mount เท่านั้น — SSR ไม่มี window (กัน hydration mismatch)
|
||||
const [origin, setOrigin] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
// query ชุดเดียวกันทั้ง preview และตอนกดปุ่ม — สิ่งที่เห็นคือสิ่งที่ถูกเปิดเป๊ะ
|
||||
const params = new URLSearchParams();
|
||||
if (fileUrl.trim()) params.set("fileUrl", fileUrl.trim());
|
||||
if (fileId.trim()) params.set("fileId", fileId.trim());
|
||||
if (typeId.trim()) params.set("typeId", typeId.trim());
|
||||
const queryString = params.toString();
|
||||
const fullLink = origin
|
||||
? `${origin}${withBasePath("/annotate")}${queryString ? `?${queryString}` : ""}`
|
||||
: "";
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!fileUrl.trim()) {
|
||||
setError("กรอกลิงก์ไฟล์ก่อน");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
||||
router.push(`/annotate?${queryString}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mt-8 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 text-primary" />
|
||||
<p className="text-sm font-medium text-slate-700">เปิดเอกสารจากลิงก์ e-Court</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-x-2 gap-y-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="ecourt-file-url"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
ลิงก์ไฟล์
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-file-url"
|
||||
name="fileUrl"
|
||||
required
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…"
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ecourt-file-id"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
รหัสเอกสาร fileId
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-file-id"
|
||||
name="fileId"
|
||||
value={fileId}
|
||||
onChange={(e) => setFileId(e.target.value)}
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ecourt-type-id"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
กลุ่ม typeId
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-type-id"
|
||||
name="typeId"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
placeholder="เช่น 1"
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90"
|
||||
>
|
||||
เปิดเอกสาร
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
|
||||
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
||||
<span className="font-medium text-slate-600">ลิงก์เต็ม: </span>
|
||||
{fullLink || "…"}
|
||||
</p>
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/")
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
|
||||
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
|
||||
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ const INPUT_CLASS =
|
|||
const SELECT_CLASS =
|
||||
"block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50";
|
||||
|
||||
export default function LoginForm() {
|
||||
export default function LoginForm({ next }: { next: string | null }) {
|
||||
const router = useRouter();
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -32,7 +33,7 @@ export default function LoginForm() {
|
|||
try {
|
||||
const result = await login(usernameInput.value, passwordInput.value);
|
||||
if (result.ok) {
|
||||
router.replace("/");
|
||||
router.replace(next ?? "/");
|
||||
} else {
|
||||
setError(result.error);
|
||||
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ import {
|
|||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { isHttpUrl } from "@/utils/external-file";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { decodeTiffPages } from "@/utils/tiff-client";
|
||||
|
|
@ -1559,7 +1560,11 @@ export default function PDFViewer({
|
|||
setTool((prev) => (prev === "highlight" ? "select" : prev));
|
||||
const objectUrls: string[] = [];
|
||||
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
||||
const fileUrl = withBasePath(file.pdfUrl);
|
||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
|
||||
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
||||
const fileUrl = isHttpUrl(file.pdfUrl)
|
||||
? withBasePath(`/api/pdf?fileId=${file.id}`)
|
||||
: withBasePath(file.pdfUrl);
|
||||
(async () => {
|
||||
let pages: { src: string | null; width: number; height: number }[];
|
||||
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {
|
||||
|
|
|
|||
32
db/schema.ts
32
db/schema.ts
|
|
@ -8,6 +8,7 @@ import {
|
|||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
|
|
@ -124,15 +125,28 @@ export type AnnotationData =
|
|||
|
||||
// ─── Tables ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const files = pgTable("files", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
pdfUrl: text("pdf_url").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
export const files = pgTable(
|
||||
"files",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
pdfUrl: text("pdf_url").notNull(),
|
||||
/** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:<fileId>" / "url:<href>") — NULL = ไฟล์ seed/อัปโหลด */
|
||||
externalId: text("external_id"),
|
||||
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` — NULL = ไม่มีกลุ่ม)
|
||||
* ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */
|
||||
typeId: text("type_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index("files_type_id_idx").on(t.typeId),
|
||||
// unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (external_id = NULL) ไม่ชนกัน
|
||||
uniqueIndex("files_external_id_key").on(t.externalId),
|
||||
],
|
||||
);
|
||||
|
||||
export const annotations = pgTable(
|
||||
"annotations",
|
||||
|
|
|
|||
111
docs/PLAN-API.md
Normal file
111
docs/PLAN-API.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key)
|
||||
|
||||
> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication"
|
||||
> Dependency: **N8** (`docs/PLAN-ECOURT.md` — `external_id` สำหรับค้นด้วย fileId ของ e-Court) + **N9** (`docs/PLAN-DOCSET.md` — `files.type_id` สำหรับค้นตามกลุ่ม)
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N10**) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N10`
|
||||
|
||||
## Context
|
||||
|
||||
- ผู้เรียกคือ **backend ของ e-Court** (server-to-server — ไม่มี browser ไม่มีหน้า UI) ต้องการดึงข้อมูลที่เกิดในระบบเรากลับไปใช้ต่อ: annotation/bookmark ของเอกสารหนึ่งฉบับ หรือของทุกเอกสารในกลุ่ม (`typeId`) เดียวกัน
|
||||
- ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี
|
||||
- `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม
|
||||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22)
|
||||
|
||||
1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key)
|
||||
2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:<fileId>"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา
|
||||
3. **2 endpoints ตาม pattern query-param ของบ้าน** (เหมือน `/api/annotations/export?fileId=`) ไม่ใช้ path parameter
|
||||
4. **`include` เลือกส่วนที่เอา**: `annotations`, `bookmarks` คั่น comma — default = ทั้งสอง ("เอาทั้งหมด"), `include=bookmarks` = เฉพาะ bookmark — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค)
|
||||
|
||||
## Design
|
||||
|
||||
### D1 — Auth (`lib/external-api-auth.ts` ใหม่)
|
||||
|
||||
- `requireExternalApiKey(req: Request): Response | null` — คืน `null` เมื่อผ่าน / คืน `Response` 401 เมื่อไม่ผ่าน (body JSON `{ "error": "<ข้อความไทย>" }` + `content-type: application/json; charset=utf-8`)
|
||||
- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร)
|
||||
- **env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request** (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี
|
||||
- ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed")
|
||||
|
||||
### D2 — Endpoints (2 routes ใหม่)
|
||||
|
||||
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https://<host>/docedit/note/api/external/...`
|
||||
|
||||
**1) เอกสารเดียว** — `GET /api/external/document?fileId=<fileId ของ e-Court>&include=annotations,bookmarks`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"document": { // null = 404 (ไม่เคยเปิดในระบบ)
|
||||
"id": "<uuid ของเรา>", // ใช้อ้างต่อใน DTO ข้างล่าง
|
||||
"externalFileId": "<fileId ของ e-Court>",
|
||||
"title": "…",
|
||||
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
|
||||
"createdAt": "<ISO string>"
|
||||
},
|
||||
"annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "replies": ReplyDTO[] ต่อรายการ ],
|
||||
"bookmarks": [ BookmarkDTO ตาม db/schema.ts ]
|
||||
}
|
||||
```
|
||||
|
||||
**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=<typeId>&include=…`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"typeId": "…",
|
||||
"documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1
|
||||
}
|
||||
```
|
||||
|
||||
- **ไม่มีเอกสารในกลุ่ม = 200 + `documents: []`** (ต่างจากหน้า UI `/groups/[typeId]` ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration)
|
||||
- ลำดับ: `documents` ตาม `files.createdAt` (เหมือนหน้า `/groups/[typeId]`) · `annotations` ตาม `(pageIndex, createdAt)` เหมือนหน้าสรุป F15 · `bookmarks` ตาม N5 (`pageIndex → yOffset → createdAt`)
|
||||
- timestamp เป็น ISO string ทั้งหมด (ผ่าน mapper `toDTO` ที่มีอยู่ — invariant #5), `data` คือ JSONB ตาม discriminated union เดิม
|
||||
- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (param ขาด/`include` ค่าไม่รู้จัก), 401 (key), 404 (เอกสารเดียว: fileId ไม่เคยลงทะเบียน)
|
||||
|
||||
### D3 — การ query / สิทธิ์ / ความปลอดภัย
|
||||
|
||||
- read-only ทั้งหมด: **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** — snapshot ณ ตอนเรียก (คนละ context กับ Server Action ใน invariant #4)
|
||||
- ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียก; ไม่เปิด `Access-Control-Allow-Origin`)
|
||||
- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
|
||||
- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น
|
||||
- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO)
|
||||
- route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
|
||||
|
||||
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
|
||||
|
||||
| ไฟล์ | สิ่งที่แก้ |
|
||||
|---|---|
|
||||
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N10 · API ดึงข้อมูลเอกสาร/กลุ่ม (API Key)** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-API.md`) |
|
||||
| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `app/api/external/document/route.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) |
|
||||
| `.claude/rules/data-layer.md` | เพิ่ม: routes ของ N10 เป็น read-only ไม่ publish SSE/`revalidatePath`; env `EXTERNAL_API_KEY` ว่าง = fail closed |
|
||||
| `README.md` | สารบัญ env เพิ่ม `EXTERNAL_API_KEY` + ตัวอย่างการเรียก (curl) |
|
||||
|
||||
## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน)
|
||||
|
||||
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|
||||
|---|---|---|---|
|
||||
| 1 | Docs sync N10 | ตารางด้านบน | สเปก §7 ครบตาม format; README มี env + ตัวอย่าง |
|
||||
| 2 | Auth helper | `lib/external-api-auth.ts` | unit test (vitest): ไม่ใส่ header / key ผิด / env ว่าง = 401; key ถูก = ผ่าน — ไม่มีข้อความบอกว่าขาดอะไร |
|
||||
| 3 | Route เอกสารเดียว | `app/api/external/document/route.ts` | curl: 200 รูปร่างตาม D2 (replies ใน annotation, ISO timestamps), `include=bookmarks` → `annotations: []`, fileId ไม่รู้จัก = 404, `include=foo` = 400 |
|
||||
| 4 | Route กลุ่ม | `app/api/external/documents/route.ts` | curl: หลายไฟล์เรียง `createdAt`, typeId ไม่มี = `documents: []`, รูปร่าง document เหมือน endpoint 1 เป๊ะ |
|
||||
| 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; ทดสอบร่วมกับ N8/N9 ด้วยข้อมูลจริงจากลิงก์ e-Court |
|
||||
|
||||
## Edge cases (ออกแบบไว้ล่วงหน้า)
|
||||
|
||||
1. **env `EXTERNAL_API_KEY` ไม่ตั้ง/ว่าง** → 401 ทุก request (fail closed) — deploy ต้องตั้งก่อนเปิดใช้
|
||||
2. **key ผิด** → 401 ข้อความเดียวกับไม่ใส่ key (ไม่บอกว่าขาดอะไร — กัน enumerate)
|
||||
3. **fileId ที่ e-Court ไม่เคยลิงก์มา** → 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)" — ไม่ใช่ 500
|
||||
4. **ไฟล์ลงทะเบียนแบบ `"url:<href>"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน
|
||||
5. **`include` ซ้ำค่า / มี whitespace / ค่าไม่รู้จัก** → trim + dedupe; มีค่านอก `{annotations, bookmarks}` สักตัว = 400 บอกค่าที่รับ
|
||||
6. **ไม่มี param `fileId`/`typeId` เลย** → 400 ข้อความไทยระบุ param ที่ขาด
|
||||
7. **`typeId` ตัวเปล่า / `include` ตัวเปล่า** → 400 (ไม่ปล่อยผ่านเป็น "ทั้งหมด")
|
||||
8. **เอกสารไม่มี annotation/bookmark เลย** → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
|
||||
9. **ไฟล์ไม่มีกลุ่ม** → `document.typeId: null` (ไม่ใช่ string ว่าง)
|
||||
10. **คนละรอบเรียกพร้อมกัน** → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool `prepare: false` ตาม invariant #11)
|
||||
|
||||
## Out of scope (ไม่ทำในแผนนี้)
|
||||
|
||||
- หลาย key / ตาราง key ใน DB / เพิกถอนราย key / UI จัดการ key
|
||||
- rate limiting / pagination / caching ของ response
|
||||
- write API (สร้าง/แก้ annotation ผ่าน API) — ระบบยังเขียนผ่าน Server Actions + login เท่านั้น
|
||||
- ดึงไฟล์ PDF/bytes เอกสารผ่าน API นี้ (`/api/pdf` มีอยู่แล้วแต่ไม่มี key — ความเสี่ยงที่รับไว้ตามข้อ 3 ของ plan-reviewer ใน PLAN-ECOURT.md)
|
||||
- webhook/event push แจ้งเตือนเมื่อมี annotation ใหม่, API รูปแบบอื่น (XML/CSV)
|
||||
- auth จริงเทียบระบบ e-Court (ยัง out of scope ของโปรเจกต์)
|
||||
|
|
@ -1,130 +1,107 @@
|
|||
# แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8)
|
||||
# แผน N9 — กลุ่มเอกสารตาม `typeId` จาก e-Court + หน้ารายการเอกสารในกลุ่ม
|
||||
|
||||
> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-17
|
||||
> อ้างอิง: requirement 5.3.10 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม C**): *"แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้"* — ครึ่งแรก (รายไฟล์) ✅ ทำแล้วใน sidebar ครึ่งหลัง (ชุดของเอกสาร) คือ gap นี้
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N8**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผนนี้ แล้วรันผ่าน `/implement-feat N8`
|
||||
> สถานะ: **implement แล้ว (2026-09-23)** — `tsc` + vitest 142/142 + Playwright ผ่านทุกข้อ verify ของ task 3–4 + plan-reviewer/code-reviewer ไม่พบ violation (คงเหลือ e2e ลิงก์ e-Court จริงตามแผน ตอนปิดงาน N8) — เขียนใหม่ 2026-09-22 แทนร่างเดิม "Document Set จัดชุดมือใน UI" (2026-09-17, commit 6940b38) ตามคำขอผู้ใช้: ให้ e-Court บอกกลุ่มผ่านลิงก์แทนการจัดชุดด้วยมือ
|
||||
> เลขฟีเจอร์: ร่างเดิม (2026-09-17) เคยใช้เลข N8 — ยกให้แผนลิงก์ e-Court (`docs/PLAN-ECOURT.md`) ซึ่งเป็น dependency ต้นทาง แผนนี้จึงเป็น **N9**
|
||||
> Dependency: **N8** — การรับ/refresh `typeId` เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (แผนนี้ไม่มี code path ที่เขียน `type_id` เอง) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N9**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N9`
|
||||
|
||||
## Context
|
||||
|
||||
- ตอนนี้ไฟล์เป็น**ลิสต์แบน**หน้าเดียว (หน้า `/` เรียงตาม `createdAt`) — ไม่มี concept การจัดกลุ่มไฟล์ และ bookmark ดูได้เฉพาะภายในไฟล์ที่เปิด (แท็บ "ที่คั่นหน้า" + หน้าสรุป F15 ซึ่งเป็น annotation ไม่ใช่ bookmark)
|
||||
- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็น bookmark **รวมทุกไฟล์ของคดีเดียวกันในหน้าเดียว** แล้วคลิกกระโดดเข้าไปอ่านตรงจุดนั้น
|
||||
- ของพร้อมใช้ทั้งหมด: โครงสร้างต้นไม้ bookmark + ลำดับ N5 (`pageIndex → yOffset → createdAt`), deep link F12 (`?page=` อ่านตอน mount), mock login e-Court (ทุกหน้าถูก `middleware.ts` บังคับ login แล้ว)
|
||||
- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็นเอกสารที่เกี่ยวเนื่องกันจัดกลุ่มมาแสดง และมีหน้ารวมทุกเอกสารของกลุ่ม
|
||||
- ร่างเดิม (2026-09-17) ออกแบบเป็นตาราง `document_sets` + ย้ายไฟล์เข้า/ออกด้วยมือใน UI — ผู้ใช้ตัดสินใจใหม่ (2026-09-22): ให้ e-Court ส่งรหัสกลุ่มมากับลิงก์ (`?typeId=` — เปลี่ยนชื่อจาก caseId ตามคำขอ) จึง**ไม่ต้องมีตารางแยก / Server Actions / UI จัดการกลุ่มเลย** เหลือแค่ column + การแสดงผล
|
||||
- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** พร้อมหมายเหตุให้ผู้ใช้ทราบว่าอาจไม่ครบทุกฉบับของคดี (รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา)
|
||||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-17)
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22)
|
||||
|
||||
1. **ไฟล์หนึ่งอยู่ชุดเดียว** — column `set_id` (nullable) บนตาราง `files` — ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน)
|
||||
2. **หน้า bookmark รวมชุดเป็นหน้าใหม่ `/sets/[id]`** เข้าจากหน้ารายการ (หน้า `/` จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น)
|
||||
3. **จัดการชุดผ่าน UI บนหน้ารายการ** — ฟอร์มเล็ก "สร้างชุด" + ปุ่มบนการ์ดไฟล์ย้ายเข้า/ออกชุด + เมนูแก้ชื่อ/ลบชุด (ทุกคนที่ login จัดการได้ — roles ยัง out of scope ตาม 5.3.23)
|
||||
1. **กลุ่มระดับเดียว กำหนดด้วย `typeId` จากลิงก์ e-Court** — เพิ่ม column บนตาราง `files` ค่าเดียว (`type_id` — ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22, แสดงผลด้วยรหัสกลุ่มโดยตรง) — ไม่มีตารางกลุ่มแยก ไม่มี many-to-many ไม่มีหลายระดับ (เอกสารหนึ่งอยู่กลุ่มเดียวตามธรรมชาติของงาน) — ความหมายของ id เป็นของ e-Court ฝั่งเราถือว่า "ส่งมาเหมือนกัน = กลุ่มเดียวกัน"
|
||||
2. **เข้ามาอัตโนมัติทั้งหมด ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น
|
||||
3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ"
|
||||
|
||||
## Design
|
||||
|
||||
### D1 — Schema (`db/schema.ts` + `drizzle/`)
|
||||
|
||||
```ts
|
||||
export const documentSets = pgTable("document_sets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
// files เพิ่ม:
|
||||
setId: uuid("set_id").references(() => documentSets.id, { onDelete: "set null" }),
|
||||
// + index ("files_set_id_idx") บน set_id
|
||||
// files เพิ่ม (เก็บเฉพาะเอกสารนำเข้าจากลิงก์ — ไฟล์ seed/อัปโหลด = NULL เสมอ):
|
||||
typeId: text("type_id"),
|
||||
// + index ("files_type_id_idx") บน type_id
|
||||
```
|
||||
|
||||
- ตาราง `document_sets` ประกาศ**ก่อน** `files` (drizzle `references` ต้องเห็น table ก่อน)
|
||||
- **ไม่เก็บผู้สร้างชุด** (ไม่มี user_id/user_name) — ชุดเป็นของกลางของคดี ใครก็จัดการได้ และไม่มี UI ใดในสเปกนี้ต้องโชว์ผู้สร้าง
|
||||
- `onDelete: "set null"` — ลบชุดแล้วไฟล์ในชุดตกกลับเป็น "ไม่อยู่ชุด" **ไฟล์ไม่ถูกลบ** (คนละเรื่องกับ CASCADE ของ annotations ที่ผูกไฟล์)
|
||||
- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ส่งข้ามไป client เฉพาะ `{id, title}` ธรรมดา (createdAt ไม่ต้องส่ง — ไม่มี UI โชว์; ถ้าส่งต้อง ISO string ตาม invariant #5)
|
||||
- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น
|
||||
- ไม่มี FK/ตาราง `document_sets` (ต่างจากร่างเดิม) — กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query
|
||||
- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น; ไฟล์ generated idempotent (`IF NOT EXISTS`)
|
||||
- การเขียน `type_id` ทั้งหมดเป็นของ N8 `registerExternalFile` (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ดู PLAN-ECOURT.md D1) — แผนนี้**อ่านอย่างเดียว**
|
||||
- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ข้อมูลข้ามไป client เป็น plain object `{id, title, typeId}` (ถ้าต้องส่ง `createdAt` ข้าม boundary = ISO string ตาม invariant #5)
|
||||
|
||||
### D2 — Server Actions (`app/actions/sets.ts`)
|
||||
### D2 — หน้ารายการ `/` จัดกลุ่ม (`app/page.tsx`)
|
||||
|
||||
`"use server"` + validate ก่อนทุกครั้ง (pattern `app/actions/files.ts`):
|
||||
- query `files` เหมือนเดิม → แบ่งเป็น section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม
|
||||
- หัวกลุ่ม = `type_id` + จำนวนไฟล์ + **ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`** (icon Folder)
|
||||
- ไฟล์ที่ `type_id` NULL (seed / อัปโหลด N4 / ลิงก์แบบไม่มี typeId) → section **"ไม่มีกลุ่ม"** ไว้ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร) — แสดงเสมอถ้ามีไฟล์
|
||||
- ไม่มี client component ใหม่ ไม่มี action ใหม่ — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม
|
||||
|
||||
- `createSet(title)` — title trim, ว่าง = error, ยาว ≤ 200 (เทียบ `files.title`) → insert
|
||||
- `renameSet(setId, title)` — validate UUID + title เหมือนกัน + ชุุดมีจริง → update
|
||||
- `deleteSet(setId)` — validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK)
|
||||
- `setFileSet(fileId, setId | null)` — validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → update `files.set_id`
|
||||
- **ไม่ publish SSE event** — การจัดชุดไม่แตะข้อมูลรายไฟล์ใน viewer (annotation/bookmark เดิมทั้งหมด) จึงไม่มีอะไรให้ sync ผ่าน `/api/annotations/stream` (ซึ่งผูก `fileId` รายไฟล์อยู่แล้ว) — เหมือน `uploadFile` ที่ใช้ `revalidatePath` อย่างเดียว
|
||||
- **revalidatePath ทุก action:** `/` เสมอ + `/sets/{setId}` เมื่อแก้ชุดนั้น (rename) — กัน Router Cache ฝั่ง client ค้างหน้าเก่า (เหตุผลเดียวกับ `revalidateFilePages`)
|
||||
- สิทธิ์: ทุกคนที่ login — action เรียก `getSessionUser()` defense ซ้อน middleware (คืน error ถ้าไม่มี session)
|
||||
### D3 — หน้า `/groups/[typeId]` แสดงเอกสารในกลุ่ม (`app/groups/[typeId]/page.tsx`)
|
||||
|
||||
### D3 — หน้ารายการ `/` จัดกลุ่มตามชุด
|
||||
Server component `force-dynamic` (login ถูกบังคับโดย `middleware.ts` อยู่แล้ว — path ใหม่เข้า net อัตโนมัติ):
|
||||
|
||||
- query `files` + `documentSets` → จัดกลุ่ม: **section ต่อชุด** (เรียง `createdAt`) — หัวชุด = ชื่อชุด + จำนวนไฟล์ + **ลิงก์ "bookmark ของชุด" → `/sets/{id}`** (icon Bookmark) + เมนู ⋯ (แก้ชื่อ / ลบชุด — confirm ก่อนลบ)
|
||||
- การ์ดไฟล์ในชุดเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม + **ปุ่ม "ใส่ชุด"** เปิด dropdown เลือกชุดที่มีอยู่ / "ออกจากชุด" (ถ้าอยู่ชุด)
|
||||
- ไฟล์ที่ `set_id` null → section **"ไม่อยู่ชุด"** ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง
|
||||
- ฟอร์มเล็ก **"สร้างชุด"** — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม, `useTransition` + action `createSet`)
|
||||
- client component ใหม่: `components/CreateSetForm.tsx` (ฟอร์มสร้างชุด), `components/SetFileMenu.tsx` (dropdown ย้ายเข้า/ออกชุดบนการ์ด + เมนูแก้ชื่อ/ลบของหัวชุด — รับ prop list ชุุด `{id, title}[]` จาก server)
|
||||
- โหลด: `files where type_id = params.typeId` `orderBy createdAt` (Next decode dynamic param ให้อัตโนมัติ — **อย่าเรียก `decodeURIComponent` ซ้ำ** ตาม edge case #5) — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ย้ายไฟล์ออกหมดแล้ว)
|
||||
- แสดง: หัวกลุ่ม (`type_id` + "N เอกสาร") + ลิงก์กลับ `/`
|
||||
- **หมายเหตุกำกับทุกครั้ง** (ผู้ใช้ยืนยัน): "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม" (กล่อง info ใต้หัวกลุ่ม)
|
||||
- รายการเอกสาร: ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม/ลิงก์ **Annotate → `/annotate/{fileId}`** (ไม่ต้อง deep link หน้า — เข้าไปเหมือนปกติ)
|
||||
- ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) ไม่มีปุ่มแก้ไข/ย้ายไฟล์ (จัดการที่ e-Court ฝั่งเดียว)
|
||||
- `generateMetadata` ตั้งชื่อแท็บ browser ตามชื่อกลุ่ม (pattern เดียวกับหน้าสรุป F15)
|
||||
|
||||
### D4 — หน้า `/sets/[id]` — bookmark รวมทั้งชุด (`app/sets/[id]/page.tsx`)
|
||||
### D4 — สิทธิ์/การเข้าถึง
|
||||
|
||||
Server component `force-dynamic` (มี login check ซ้อน middleware เหมือนหน้า `/`):
|
||||
|
||||
- โหลด: ชุุด (ไม่มี = 404) + ไฟล์ในชุุด (`orderBy createdAt`) + bookmark ของทุกไฟล์ (`inArray(fileId, …)` `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` — N5)
|
||||
- แสดง: หัวชุด (ชื่อ + "N เอกสาร · M รายการ") + ลิงก์กลับ `/`
|
||||
- **จัดกลุ่มตามไฟล์** (หัวไฟล์ = ชื่อไฟล์) ตามลำดับไฟล์ในชุด → ข้างในแสดง**ต้นไม้ bookmark 3 ระดับ** (indent เหมือน sidebar — build `childrenOf`/`roots` จาก array ที่เรียง N5 แล้ว + orphan → root + cycle guard seen ชุดเดียวร่วมทุก root เหมือน pattern N7/route เดิม) **read-only**: ชื่อ + "หน้า N · ผู้บันทึก · วันที่" + โน้ต — คลิกทั้งแถว = ลิงก์ **`/annotate/{fileId}?page={หน้า}`** กระโดดเข้า viewer ตรงหน้า (deep link F12 — ⚠️ implement ต้องเช็คจาก F12 จริงว่า `?page=` เป็น 1-based หรือ 0-based ให้ตรงกับปุ่มกระโดดของหน้าสรุป F15)
|
||||
- ไม่มีปุ่มแก้ไข/ลบ bookmark ที่นี่ (จัดการที่ viewer เท่านั้น) + ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15)
|
||||
- ปุ่มส่งออก JSON (N7) **ไม่มี**หน้านี้ — N7 รับ `fileId` เดียว การขยายเป็นชุดอยู่ใน out of scope
|
||||
|
||||
### D5 — สิทธิ์/การเข้าถึง
|
||||
|
||||
- `middleware.ts` บังคับ login ทุก path อยู่แล้ว — `/sets/*` + actions เข้า่ายใน net อัตโนมัติ
|
||||
- ใครก็จัดการชุดได้ (สร้าง/แก้ชื่อ/ลบ/ย้ายไฟล์) — ไม่มี roles (5.3.23 ยัง out of scope)
|
||||
- ใครก็ที่ login เห็นได้ทุกกลุ่ม (mock login — roles ยัง out of scope ตามแผนหลัก)
|
||||
- ไม่มี Server Action ใหม่ → ไม่มี publish SSE / `revalidatePath` ของแผนนี้ (การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก `/` ทันที — ไม่มี Router Cache ค้าง)
|
||||
|
||||
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
|
||||
|
||||
| ไฟล์ | สิ่งที่แก้ |
|
||||
|---|---|
|
||||
| `docs/PLAN.md` §3 | เพิ่มตาราง `document_sets` + column `files.set_id` (FK SET NULL) — rule data-layer: ห้ามเพิ่มตารางโดยไม่แก้ PLAN.md ก่อน |
|
||||
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N8 · ชุดเอกสาร + bookmark รวมทั้งชุด** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) |
|
||||
| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N8 + โครงสร้างไฟล์เพิ่ม `app/actions/sets.ts`, `app/sets/[id]/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` |
|
||||
| `.claude/rules/data-layer.md` | เพิ่ม: `document_sets` + `files.set_id` (FK **SET NULL** ไม่ใช่ CASCADE), actions ของ sets **ไม่ publish SSE** (ไม่แตะข้อมูลรายไฟล์) แต่ `revalidatePath("/")` (+`/sets/[id]` ตอน rename) เสมอ |
|
||||
| `docs/PLAN.md` §3 | เพิ่ม column `files.type_id` (text NULL + index — ไม่มีตารางกลุ่ม/ไม่มีคอลัมน์ชื่อกลุ่ม) + `files.external_id` (ค้างจาก N8 — ทำพร้อมกันตอน sync 2026-09-23) |
|
||||
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N9 · กลุ่มเอกสารตาม typeId + หน้า /groups/[typeId]** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) |
|
||||
| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N9 + โครงสร้างไฟล์เพิ่ม `app/groups/[typeId]/page.tsx` |
|
||||
| `.claude/rules/data-layer.md` | เพิ่ม: `files.type_id` เขียนที่ N8 `registerExternalFile` เท่านั้น (ลิงก์ไม่ส่ง = คงเดิม) — ไม่มี action/SSE ใหม่ของ N9 |
|
||||
|
||||
- `docs/requirement/gap-analysis.md` — ปรับ 5.3.10 ⚠️ → ✊ **หลัง implement เสร็จเท่านั้น** (task ปิดงาน) ไม่ใช่ตอนนี้
|
||||
- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด)
|
||||
- `docs/requirement/gap-analysis.md` 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร") — **ยังไม่ปิด** ด้วยแผนนี้ (แผนนี้แสดง *เอกสาร* รวมกลุ่ม ไม่ใช่ *bookmark* รวมกลุ่มตาม requirement) — อย่าเปลี่ยนสถานะ
|
||||
- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ**
|
||||
|
||||
## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน)
|
||||
|
||||
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|
||||
|---|---|---|---|
|
||||
| 1 | Docs sync N8 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มีตาราง/column ใหม่ |
|
||||
| 2 | Schema + migrate | `db/schema.ts`, `drizzle/` | `/sync-db` + `drizzle-kit generate` → psql `\d files` เห็น `set_id` + `\d document_sets` มีตาราง; SQL generated idempotent (`IF NOT EXISTS`) |
|
||||
| 3 | Server Actions | `app/actions/sets.ts` | ผ่าน UI ใน task 4–5; title ว่าง/ยาว = error; setId/fileId ไม่มีจริง = error ไม่ throw ระเบิดหน้า |
|
||||
| 4 | หน้ารายการจัดกลุ่มชุด + ฟอร์มสร้าง + ย้ายไฟล์ | `app/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | Playwright: สร้างชุุด → การ์ดจัดกลุ่มถูก → ย้ายไฟล์เข้า/ออกชุด → แก้ชื่อ/ลบชุดแล้วไฟล์ไม่หาย (กลับ "ไม่อยู่ชุด"); **regression: อัปโหลด N4 + Annotate ยังทำงาน** |
|
||||
| 5 | หน้า `/sets/[id]` | `app/sets/[id]/page.tsx` | Playwright: bookmark หลายไฟล์แสดงครบ เรียง ไฟล์ → N5 ต้นไม้ indent ถูก; คลิกกระโดด `/annotate/{fileId}?page=` แล้ว viewer ไปถูกหน้า; ชุดว่าง / ไฟล์ไม่มี bookmark / ชุดที่ถูกลบ = 404 |
|
||||
| 6 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc` + gap-analysis 5.3.10 → ✅ |
|
||||
| 1 | Docs sync N9 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มี column ใหม่ |
|
||||
| 2 | Schema + migrate — **ทำแล้วกับ N8** (`type_id` + `files_type_id_idx` อยู่ใน `db/schema.ts` + migration `drizzle/0002` idempotent + dev DB ยืนยันด้วย psql 2026-09-23) | `db/schema.ts`, `drizzle/` | ตรวจแล้วผ่าน: psql `\d files` เห็น `type_id` + index (ไม่มี type_name) |
|
||||
| 3 | หน้า `/` จัดกลุ่ม | `app/page.tsx` | ทดสอบด้วยลิงก์จำลอง (การ์ด `EcourtLinkForm` / path `/samples/…` + `typeId` — N8 พร้อมแล้ว) หรือ update `files` set `type_id` ตรง ๆ ก็ได้: จัด section ถูก / "ไม่มีกลุ่ม" ท้ายสุด; **regression: อัปโหลด N4 + Annotate ยังทำงาน** |
|
||||
| 4 | หน้า `/groups/[typeId]` | `app/groups/[typeId]/page.tsx` | รายการครบถูกลำดับ + หมายเหตุแสดง + นับ annotation/bookmark ตรง + กด Annotate เปิดไฟล์ได้; typeId ไม่รู้จัก = 404; ชื่อแท็บ browser ตามกลุ่ม |
|
||||
| 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; e2e เต็ม (ลิงก์ e-Court จริง) ทำตอนปิดงาน N8 |
|
||||
|
||||
## Edge cases (ออกแบบไว้ล่วงหน้า)
|
||||
|
||||
1. **ลบชุดที่มีไฟล์** → FK `ON DELETE SET NULL` — ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน)
|
||||
2. **เข้า `/sets/[id]` ชุดที่ถูกลบ** → 404
|
||||
3. **ชื่อชุดซ้ำ** → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ)
|
||||
4. **ชื่อชุดว่าง/whitespace/ยาวเกิน 200** → action validate (trim → ว่าง = error, clamp 200)
|
||||
5. **ชุดว่าง (ไม่มีไฟล์)** → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า `/` ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า)
|
||||
6. **ไฟล์ในชุดไม่มี bookmark** → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ)
|
||||
7. **cycle ใน bookmark tree** (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1
|
||||
8. **`?page=` format ของ F12** → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา
|
||||
9. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด
|
||||
10. **ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม** → update ค่าเดิม idempotent ไม่ error
|
||||
1. **ลิงก์รอบใหม่ส่ง `typeId` คนละค่า** → ย้ายกลุ่มตามค่าล่าสุด (เกิดที่ N8) — annotation/bookmark ผูกกับ `file_id` ไม่เกี่ยวกับกลุ่ม จึงตามไฟล์ไปเอง
|
||||
2. **ลิงก์รอบใหม่ไม่ส่ง `typeId`** → ไฟล์คงกลุ่มเดิม (N8: ห้ามเคลียร์ค่าเดิม)
|
||||
3. **ไฟล์ไม่เคยมี `typeId`** (seed / อัปโหลด N4 / ลิงก์เก่า) → section "ไม่มีกลุ่ม" บนหน้า `/`, ไม่มีหน้ากลุ่ม
|
||||
4. **กลุ่มแสดงผลด้วยรหัสล้วน** — ไม่มีคอลัมน์ชื่อกลุ่ม (ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22) — ถ้าภายหลังต้องการชื่อกลุ่ม กลับมาเพิ่ม column ใหม่ตาม governance
|
||||
5. **`typeId` มีอักขระพิเศษ/ยาว** → ผ่าน URL ด้วย `encodeURIComponent`; normalize ที่ N8 (trim → ว่าง = NULL, cap 200); Next decode ให้อัตโนมัติตอนอ่าน `params`
|
||||
6. **กลุ่มว่างลง (ไฟล์ย้ายออกหมด)** → กลุ่มหายจากหน้า `/` เอง และ `/groups/[typeId]` กลายเป็น 404 (query จาก `files` จริง ไม่มีตารางกลุ่มค้าง)
|
||||
7. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทสหน้า UI ด้วยการตั้ง `type_id` บน id ที่จับเป๊ะ ไม่ reset ข้อมูลทั้งกลุ่ม
|
||||
8. **N8 ยังไม่ implement ตอนทำ N9** → ~~verify หน้า UI ด้วยการเขียน DB ตรง (task 3–4) แล้วรอ e2e จริงตอนปิดงาน N8~~ (ล้าสมัย — N8 implement ครบแล้ว 2026-09-22: verify ได้ด้วยลิงก์จำลอง/การ์ด `EcourtLinkForm` + path `/samples/…` ทันที)
|
||||
9. **`typeId` มี `/` (เช่น เลขคดีไทย `ด2024/2567`)** → ลิงก์ `/groups/…%2F…` — Next 14 route ได้ (match บน raw path ก่อน decode) แต่ reverse proxy บางตัว (Tomcat/JBoss ปฏิเสธ `%2F` โดย default) อาจ 404 ทั้งที่กลุ่มมีจริง — **known risk ที่รับไว้** (code-reviewer 2026-09-23): ทดสอบ e2e กับ deployment จริงของ e-Court ตอนปิดงาน N8 ถ้าเจอปัญหาแก้ที่จุดสร้างลิงก์จุดเดียว (replace `/` ด้วยตัวอักขระปลอดภัยตอน encode) หรือเปลี่ยนเป็น query param
|
||||
|
||||
## Out of scope (ไม่ทำในแผนนี้)
|
||||
|
||||
- ไฟล์อยู่หลายชุด (many-to-many) — ตัดสินใจแล้ว: ชุุดเดียว
|
||||
- export/JSON bookmark **ตามชุด** (ขยาย N7 รับ `setId`) — N7 ยังรับ `fileId` เดียว
|
||||
- roles/สิทธิ์การจัดการชุด (gap 5.3.23)
|
||||
- reorder ไฟล์ในชุด / reorder ชุด / ค้นหาข้ามชุด (ขยาย N6 เป็นหลายไฟล์)
|
||||
- หน้ารวมทุกชุด `/sets` (ทางเข้าอยู่หน้ารายการแล้ว)
|
||||
- seed ตัวอย่างชุดใน `npm run seed-samples` (สร้างผ่าน UI ได้ — ไม่บังคับ)
|
||||
- annotation รวมชุด (bookmark พอตาม requirement 5.3.10)
|
||||
- จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI ของเรา (ทิ้งจากร่างเดิม — กลุ่มมาจาก e-Court อย่างเดียว)
|
||||
- ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ
|
||||
- **bookmark รวมทั้งกลุ่ม** (ครึ่งหลังของ requirement 5.3.10 แบบร่างเดิม — น่าดึงกลับมาภายหลังได้เมื่อผู้ใช้ขอ)
|
||||
- export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว)
|
||||
- API ดึงข้อมูลให้ระบบภายนอก = **N10** `docs/PLAN-API.md` (แผนแยก)
|
||||
- รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court)
|
||||
|
||||
## Verification (ภาพรวม end-to-end)
|
||||
## Verification (ภาพรวม end-to-end — หลัง N8 เสร็จ)
|
||||
|
||||
1. `npm run dev` → login → หน้า `/`: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด
|
||||
2. หัวชุด → "bookmark ของชุด" → `/sets/[id]`: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก
|
||||
3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้น**ถูกหน้า**
|
||||
4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย
|
||||
5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม
|
||||
1. `npm run dev` → login → เปิดลิงก์ e-Court 2 ไฟล์ที่ `typeId` เดียวกัน + 1 ไฟล์ `typeId` ต่างกลุ่ม → หน้า `/` จัด 2 section ตามกลุ่ม + section "ไม่มีกลุ่ม" (ไฟล์ seed/อัปโหลด)
|
||||
2. หัวกลุ่ม → "เอกสารในกลุ่ม" → `/groups/[typeId]`: เห็นไฟล์ครบตามลำดับ + หมายเหตุ "อาจไม่ครบทุกฉบับ" + จำนวน annotation/bookmark ต่อไฟล์
|
||||
3. กด Annotate → เข้า viewer ของไฟล์นั้นได้ตามปกติ
|
||||
4. เปิดลิงก์รอบใหม่ของไฟล์เดิมด้วย `typeId` คนละค่า → ไฟล์ย้ายกลุ่ม กลุ่มเดิมลดจำนวน/หายไปเมื่อว่าง
|
||||
5. Regression: อัปโหลด N4 / annotate / ค้นหา N6 / export JSON N7 ยังทำงานเหมือนเดิม
|
||||
|
|
|
|||
112
docs/PLAN-ECOURT.md
Normal file
112
docs/PLAN-ECOURT.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# แผน N8 — เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` (metadata-only + ดึงไฟล์สด)
|
||||
|
||||
> อ้างอิง: PLAN-FEAT.md §7 **N8** · สร้าง 2026-09-18 ตามข้อตกลงกับผู้ใช้ (สัญญาลิงก์ `fileUrl`+`fileId`, **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ**, id เปลี่ยน = ไฟล์ใหม่) · อัปเดต 2026-09-22: เพิ่ม param `typeId` จัดกลุ่มเอกสารที่เกี่ยวเนื่องกัน (ส่งต่อให้ **N9** `docs/PLAN-DOCSET.md`) + แก้สถานะการ implement ตามที่ตรวจพบจริง (ด้านล่าง)
|
||||
|
||||
## Context
|
||||
|
||||
ระบบ e-Court (admcasedey.admincourt.go.th) จะลิงก์เข้ามาที่ app รูปแบบ:
|
||||
|
||||
```
|
||||
https://ecourt.chamomind.com/docedit/docedit/note?fileUrl=<URL-encoded URL ไฟล์ PDF>&fileId=<id เอกสาร e-Court>&typeId=<รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน>
|
||||
```
|
||||
|
||||
ตัวอย่าง URL ไฟล์: `https://admcasedey.admincourt.go.th/admCaseEfiling/sysDocument/Attach/Temp/9/tmp_efile_tu_2_64373_0_2026-09-01_13-55-31_s.pdf`
|
||||
|
||||
ก่อนหน้านี้ไม่มีหน้าไหนอ่าน `searchParams` เลย (ตรวจแล้ว) — หน้า `/` เมิน query ทิ้งจึงดู "ว่างเปล่า" และสถาปัตยกรรมเดิมรองรับเฉพาะไฟล์ในเครื่อง (`files.pdf_url` = path ใน `public/` + path guard ใน `lib/server-pdf.ts`)
|
||||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-18)
|
||||
|
||||
1. **สัญญาลิงก์ = `fileUrl` จำเป็น + `fileId` แนะนำ** — มี fileId ใช้เป็น dedupe key (`external_id = "ecourt:<fileId>"`), ไม่มี fallback เป็น `"url:<href>"` · เพิ่ม 2026-09-22: **`typeId` แนะนำ** (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง `typeId` เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9)
|
||||
2. **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ** — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน `pdf_url`) **ไม่ดาวน์โหลดเขียนลง `public/uploads/`** ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว)
|
||||
3. **หลักจัดการเวอร์ชัน: id เปลี่ยน = ไฟล์ใหม่**
|
||||
|
||||
| สถานการณ์ | ผลลัพธ์ |
|
||||
|---|---|
|
||||
| `fileId` เดิม + URL เปลี่ยน (Temp link อายุใหม่) | row เดิม — ได้ annotation/bookmark เดิมครบ + `pdf_url` ถูก refresh เป็น URL ใหม่อัตโนมัติ |
|
||||
| `fileId` เปลี่ยน (e-Court ออก id ใหม่ต่อเวอร์ชัน) | row ใหม่ = เอกสารใหม่เริ่ม blank **ไม่สืบทอดของเวอร์ชันก่อน** (ไฟล์เก่ายังอยู่ในรายการ เปิดย้อนดูได้) — กัน annotation เก่าไปวาดตกตำแหน่งคลาดบนเนื้อหาที่ขยับ (พิกัดเป็น % ของหน้า) |
|
||||
|
||||
## Design
|
||||
|
||||
### D1 — Entry point + สร้าง/หา row
|
||||
|
||||
- `app/page.tsx` รับ `searchParams` (Next 14 = plain object ค่า `string | string[]` — helper `firstParam`): มี `fileUrl` → เรียก `registerExternalFile` → สำเร็จ `redirect("/annotate/<id>")` (เรียก**นอก try/catch เสมอ** — `redirect()` โยน `NEXT_REDIRECT`) / ไม่สำเร็จ render กล่อง error ไทย `role="alert"` เหนือ section "เพิ่มเอกสาร" (หน้ารายการยังแสดง ไม่ blank)
|
||||
- `lib/external-import.ts` — **server module ธรรมดา ไม่ใช่ "use server" action** (เรียกจาก server component; ไม่เปิดเป็น endpoint ให้ client ยิง): `registerExternalFile(rawFileUrl, rawFileId?)`
|
||||
1. `validateExternalFileUrl` → fail คืนข้อความไทย
|
||||
2. `externalIdFor(fileId, url)` → `select ... where external_id = key limit 1` → เจอ: ถ้า `pdf_url ≠ URL ใหม่` → `update files set pdf_url` (refresh Temp link) + ถ้าลิงก์ครั้งนี้มี `typeId` → `update files set type_id` ตามค่าล่าสุดด้วย (e-Court เปลี่ยน `typeId` = ย้ายกลุ่มตามค่าล่าสุด; **ลิงก์ไม่ส่ง `typeId` มา = คงค่าเดิม ห้ามเคลียร์**) แล้วคืน id เดิม
|
||||
3. ไม่เจอ → `insert { id: randomUUID(), title: titleFromExternalUrl(url), pdfUrl: url.href, externalId, typeId? }` — `typeId` normalize: trim → ว่าง = ไม่เก็บ (NULL), cap 200 ตัวอักษร (เทียบ title)
|
||||
4. **catch postgres `23505`** (insert ชน unique จาก 2 แท็บเปิดพร้อมกันครั้งแรก) → select ใหม่คืน row เดิม (ไม่มีไฟล์บนดิสก์ = ไม่มี cleanup)
|
||||
- **ไม่ `revalidatePath`** — เรียกระหว่าง render ของ server component ไม่ใช่ use case ที่รองรับ และไม่จำเป็น (`/` เป็น `force-dynamic` + redirect ออกทันที) ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE — เหมือน N4)
|
||||
- Schema: `files` เพิ่ม `external_id text NULL UNIQUE` + `type_id text NULL` (N9) + index `files_type_id_idx` — unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (`external_id = NULL`) ไม่ชนกัน
|
||||
|
||||
### D2 — Validation + allowlist (pure: `utils/external-file.ts`)
|
||||
|
||||
- `externalHosts()` — default `["admincourt.go.th"]` (ครอบทั้ง `admcasedey.` และ `judgement-api.`), override ด้วย env `EXTERNAL_FILE_HOSTS` (comma-separated) — **อ่านตอน call ไม่ใช่ module load** เพื่อ test ได้
|
||||
- `isAllowedExternalHost(host)` — suffix match: `host === entry || host.endsWith("." + entry)` (กัน `admincourt.go.th.evil.com` และ `xadmincourt.go.th`)
|
||||
- `validateExternalFileUrl(raw)` — `new URL` parse ได้ + `https:` เท่านั้น + ไม่มี `user:pass@` + host ผ่าน allowlist → ไม่ผ่านคืน error code (`invalid_url` / `not_https` / `has_credentials` / `host_not_allowed`)
|
||||
- `titleFromExternalUrl(url, fallbackId?)` — basename ของ pathname แล้ว `decodeURIComponent` (try/catch `URIError`) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย `/`) → fallback `fallbackId` หรือ `"เอกสารจาก e-Court"`
|
||||
- `externalIdFor(fileId?, url)` — `"ecourt:" + fileId` (trim) หรือ `"url:" + url.href`
|
||||
- `sanitizeNextPath(s)` — ใช้ได้เฉพาะขึ้นต้น `/` และไม่ใช่ `//` / `/\` (กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืน `null`
|
||||
- `isHttpUrl(s)` — absolute `http(s)://` หรือไม่ (ใช้ทั้งฝั่ง server เช็ค `pdf_url` เป็นรีโมต และฝั่ง client เลือกทางโหลดรูป)
|
||||
|
||||
### D3 — ดึงไฟล์รีโมต (server-only: `lib/remote-file.ts`)
|
||||
|
||||
- `isRemoteSource(pdfUrl)` = `isHttpUrl(pdfUrl)`
|
||||
- `fetchSourceBytes(url)` — validate ซ้ำอีกชั้น (defense in depth) → `fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) })` → ไม่ใช่ 2xx = `RemoteFileError` → **re-validate `res.url` หลัง redirect** ว่า host ยังอยู่ใน allowlist (กัน redirect chain เด้งไป host อื่น) → pre-check `Content-Length` + นับ byte ระหว่าง stream ด้วย `ReadableStream` reader ถ้าเกิน **100MB** (เท่า limit อัปโหลด N4) ยกเลิกแล้ว error → คืน `Buffer`
|
||||
- จุดใช้ 2 ที่ (pattern เดียวกัน เปลี่ยนเฉพาะแหล่ง bytes ก่อนเข้า pipeline เดิม):
|
||||
- `app/api/pdf/route.ts` — `isRemoteSource(file.pdfUrl) ? await fetchSourceBytes(...) : readPublicPdf(file.pdfUrl)` แล้ว branch รูป (`imageContentTypeFromUrl`)/PDF (`fixPdfActualText`) ทำงานต่อเหมือนเดิม
|
||||
- `app/api/annotations/export/route.ts` (F8) — จุดเดียวกันก่อน `imageDocFromBytes`/`PDFDocument.load`
|
||||
- export CSV (F16) / bookmarks JSON (N7) / หน้าสรุป (F15) ไม่อ่าน bytes อยู่แล้ว → ไม่แตะ
|
||||
|
||||
### D4 — Viewer กับ absolute URL ใน `pdf_url`
|
||||
|
||||
- `utils/base-path.ts` — `withBasePath` คืนค่าเดิมเมื่อเป็น absolute `^https?://` (ไม่งั้น prefix ทับจนพัง: `/docedit/notehttps://...`)
|
||||
- PDF branch ของ viewer โหลดผ่าน `/api/pdf?fileId=` อยู่แล้ว (same-origin — ไม่กระทบ); fallback `fetch(withBasePath(file.pdfUrl))` ทำงานผ่าน bypass (อาจโดน CORS ของปลายทางแต่เป็นแค่ fallback)
|
||||
- **รูปภาพ (N3)** โหลด static `pdfUrl` ตรง ๆ — ไฟล์รีโมตต้องเปลี่ยนไปโหลดผ่าน `withBasePath("/api/pdf?fileId=" + file.id)` แทน (route คืน bytes รูป + content-type อยู่แล้ว) — ใช้ `isHttpUrl` แยกทาง
|
||||
|
||||
### D5 — Login ไม่ทำ query หาย (deep link ของคนยังไม่ login)
|
||||
|
||||
- `middleware.ts` เดิม `url.search = ""` ตอน redirect `/login` → query หายทั้งหมด — แก้: เก็บ `next = pathname + search` ก่อนล้าง แล้ว `url.searchParams.set("next", next)` (เฉพาะ `next !== "/"`)
|
||||
- `app/login/page.tsx` รับ `searchParams` → `sanitizeNextPath` → `if (user) redirect(next ?? "/")` + ส่ง prop `next` ให้ `LoginForm` → `router.replace(next ?? "/")` (middleware เห็น path ตัด basePath แล้ว, NextURL/router ใส่ basePath คืนเอง — invariant #12)
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **URL ไม่มีนามสกุลไฟล์** → `fileKindFromUrl` default "pdf" (pdf.js ลองเปิด — เปิดไม่ได้ค่อยแสดง load error ตามข้อความเดิมของ viewer)
|
||||
- **เนื้อไฟล์เพี้ยน/เป็น HTML error page** — ไม่มีการตรวจ magic bytesตอน register (ไม่ได้ดาวน์โหลด): PDF → `fixPdfActualText`/pdf.js fail → error ข้อความเดิมของ viewer/route, รูป → `img.decode()`/`utif2` fail ฝั่ง client — ยอมรับได้เพราะแหล่งเชื่อถือได้ (allowlist)
|
||||
- **Temp link หมดอายุ** — เปิด viewer ไม่ได้ (annotation ยังอยู่ครบ) → เข้าใหม่ผ่านลิงก์ e-Court แล้ว `pdf_url` ถูก refresh กลับมาใช้ได้
|
||||
- **e-Court แก้เนื้อไฟล์ (URL เดิม/id เดิม)** — ได้เนื้อล่าสุดเสมอ; ถ้าเลย์เอาต์เลื่อน annotation เก่า (พิกัด %) อาจคลาดตำแหน่ง — ยอมรับตามท่า "ไม่เก็บสำเนา"
|
||||
- **Request คู่ขนาน URL/id เดียวกันครั้งแรก** — unique `external_id` + catch 23505 → select ใหม่คืน row เดิม
|
||||
- **e-Court ไม่ encode ค่า query** — ค่าที่มี `&` ข้างใน URL จะถูกตัด (parse ตาม standard); ตัวอย่างลิงก์จริงใช้ `?`/`-` ข้างใน path ไม่มี `&` จึงผ่านแบบไม่ encode ได้อยู่แล้ว — แนะนำฝั่ง e-Court encode เสมอ
|
||||
- **หน้ารายการ `/`** — ไฟล์นำเข้าแสดงเหมือนไฟล์อัปโหลด (badge ชนิดจาก extension ของ URL), กด Annotate = เปิดผ่าน route ดึงสดเหมือนกัน
|
||||
- **ลิงก์ครั้งหลังไม่มี `typeId`** — ไฟล์คงกลุ่มเดิม (update เฉพาะเมื่อลิงก์ส่งมา) — ย้ายกลุ่มเกิดเฉพาะเมื่อ e-Court ส่ง `typeId` คนละค่ามากับลิงก์รอบใหม่
|
||||
|
||||
## รอดำเนินการ (plan-reviewer 2026-09-18 — ผู้ใช้สั่งยังไม่ต้องแก้โค้ด บันทึกไว้ในแผนก่อน)
|
||||
|
||||
> อัปเดต 2026-09-22 (หลัง implement รอบใหม่): ข้อ 1, 2 และ 4 **ทำแล้ว** — เหลือข้อ 3 เป็นความเสี่ยงที่รับไว้
|
||||
|
||||
1. **hardening — re-check หลัง redirect ไม่ครบชุด:** `lib/remote-file.ts` ตรวจ `res.url` หลัง `redirect: "follow"` แค่ hostname (`isAllowedExternalHost`) ยังไม่เช็ค protocol — ถ้า host ใน allowlist redirect ไป `http://` (host เดิม) จะได้ bytes ผ่าน plaintext ทั้งที่ต้นทางบังคับ https → แก้โดยรัน `validateExternalFileUrl(res.url)` เต็มชุดแทนการเช็ค hostname เดี่ยว
|
||||
2. **เบา — else-branch ไม่มี guard ไฟล์เปล่า:** ใน `fetchSourceBytes` เมื่อ `res.body` เป็น null (เช่น HTTP 204) จะคืน Buffer เปล่าโดยไม่ผ่าน check `total === 0` ของ path หลัก → bytes เปล่าไหลไป pipeline แล้วพังเป็น error กลาง ๆ ไม่ใช่ข้อความไทย
|
||||
3. **known risk — `/api/pdf` + `/api/annotations/export` เป็น proxy แบบไม่ต้อง login:** routes ถูก middleware ยกเว้น (`api`) อยู่แล้ว — ใครไม่มี session แต่รู้ `fileId` (uuid จริงใน DB) ดึง bytes เอกสารคดีผ่าน server เราได้ (ไม่ใช่ SSRF อิสระ: URL มาจาก DB + allowlist) — auth จริงเป็น out of scope ของ N8 อยู่แล้ว แต่ให้ตั้งชื่อไว้ชัด ๆ ว่าเป็นความเสี่ยงที่รับไว้
|
||||
4. **เอกสาร — invariant #4 ใน CLAUDE.md ยังไม่มี carve-out:** `registerExternalFile` เขียน DB นอก Server Action + ไม่ `revalidatePath` (เหตุผล: เรียกระหว่าง render ของ server component + redirect ออกทันที + หน้า `/` force-dynamic — สมเหตุสมผล) — เหลือ sync ตัวอักษรของ invariant ให้ตรงกับ data-layer.md/แผนนี้
|
||||
|
||||
**สถานะการ implement (ตรวจซ้ำ 2026-09-22 — ยกเลิกบันทึกเดิม):** เอกสารฉบับก่อนของแผนนี้อ้างว่า implement และทดสอบผ่านแล้ว (tsc ผ่าน, vitest 153/153, ทดสอบ dev server ครบทุกเคส) — **ตรวจสอบจริงพบว่าโค้ดไม่มีอยู่ใน repo/DB เลย**: ไม่มี `lib/external-import.ts` / `utils/external-file.ts` / `lib/remote-file.ts` ใน branch ใด (ตรวจ `git log --all -S "external-import"` ว่างเปล่า), `db/schema.ts` และ DB จริง (`\d files`) ไม่มี column `external_id`, vitest ปัจจุบัน 139 tests — ผู้ใช้ยืนยัน (2026-09-22) ว่ายังไม่เคย implement / โค้ดหาย → **ถือว่าแผนนี้ยังไม่ถูก implement ทั้งหมด ต้องทำใหม่ตั้งแต่ต้นตามดีไซน์ด้านบน** และพับ 4 ข้อรอดำเนินการด้านบนเข้าไปทำพร้อมกันตอน implement
|
||||
|
||||
**ผลตรวจดีไซน์ที่ยังใช้ได้จากรอบ 2026-09-18:** invariant #1 (ไม่เขียนไฟล์), #12 (withBasePath + bypass), SSRF guard หลัก (allowlist/https/credentials/size/timeout), สถานะ 23505, middleware matcher `"/"` (root ขอ basePath ทะลุ middleware จริง — แก้ถูกจุด ไม่มี path เดิมหลุด/โดนเพิ่ม), sanitizeNextPath กัน open-redirect
|
||||
|
||||
**Implement รอบใหม่ (2026-09-22 บ่าย — ตามคำขอผู้ใช้กลางงาน "กดแล้วไปหน้า ?fileUrl=&fileId=&typeId"):** ลงมือจริงแล้วใน branch นี้:
|
||||
- **D1** ✅ `lib/external-import.ts` (`registerExternalFile` — dedupe `external_id` + refresh `pdf_url`/`type_id` + catch 23505) + schema `files.external_id` (UNIQUE nullable, migration `drizzle/0002`/`0003` idempotent) + entry point: หน้า `/` อ่าน `?fileUrl=&fileId=&typeId=` แล้ว `redirect("/annotate/<id>")` + กล่อง error ไทย `role="alert"`
|
||||
- **D2** ✅ `utils/external-file.ts` — `externalHosts` (env `EXTERNAL_FILE_HOSTS` อ่านตอน call) / suffix-match allowlist / https+credentials / `titleFromExternalUrl` / `externalIdFor` / `isHttpUrl`
|
||||
- **D3** ✅ `lib/remote-file.ts` + branch รีโมตใน `/api/pdf` และ `/api/annotations/export` — **รวม hardening ข้อ 1–2 ของ plan-reviewer** (re-validate ชุดเต็มหลัง redirect + guard body เปล่า/0 ไบต์)
|
||||
- **D4** ✅ `withBasePath` ผ่าน absolute http(s) ไปเฉย ๆ + รูปรีโมตของ viewer โหลดผ่าน `/api/pdf?fileId=`
|
||||
- **เกินแผน (ตามคำขอผู้ใช้):** `registerExternalFile` รับ **path ในเครื่อง** (`/samples/…` `/uploads/…` — decode + whitelist + path guard ว่าไฟล์มีจริง) เพิ่มจาก https เพื่อทดสอบ grouping ใน dev + การ์ดจำลองลิงก์ `components/EcourtLinkForm.tsx` บนหน้ารายการ (กรอก 3 ช่อง กด "เปิดเอกสาร" แล้ว `router.push("/annotate?fileUrl=&fileId=&typeId=")` + แถบแสดงลิงก์เต็ม absolute ตามที่พิมพ์สด ๆ)
|
||||
- **ปรับต่อเนื่อง (2026-09-22):** ถอด column `type_name` ออกจาก `files` ตามคำขอผู้ใช้ — สัญญาลิงก์เหลือ `typeId` เดี่ยว แสดงผลด้วยรหัสกลุ่มโดยตรง (migration `drizzle/0004` = `DROP COLUMN IF EXISTS`) · entry point ที่สอง **`/annotate?fileUrl=&fileId=&typeId=`** (`app/annotate/page.tsx` — การ์ด `EcourtLinkForm` พามาที่นี่ + แสดงลิงก์เต็มสด ๆ) · `registerExternalFile` เติม `https://` ให้ URL ที่ไม่มี scheme · env `EXTERNAL_FILE_HOSTS` ใช้เพิ่มโฮสต์ทดสอบใน dev (ตอนนี้ `.env` มี rd.usda.gov, pdfobject.com)
|
||||
- **D5 ✅ (2026-09-22 เย็น — ปิดท้ายแผน):** login วนกลับพร้อม query — `middleware.ts` เก็บ `next = pathname + search` แนบไป `/login` (เฉพาะ `next !== "/"`) · `app/login/page.tsx` รับ `searchParams.next` → `sanitizeNextPath` (`utils/external-file.ts` — ขึ้นต้น `/` เท่านั้น และไม่ใช่ `//`/`/\` กัน open-redirect, unit test `tests/utils/external-file.test.ts`) → มี session `redirect(next ?? "/")` + ส่ง prop `next` ที่ sanitize แล้วให้ `LoginForm` → `router.replace(next ?? "/")` · sync สเปกลง `PLAN-FEAT.md` §7 N8 แล้ว
|
||||
- **Verify:** `tsc` ผ่าน + Playwright ผ่านครบ (login → กรอกการ์ด → redirect เข้า `/annotate/<uuid>` → canvas render จริง / ลิงก์รอบสอง `typeId` ต่างค่าได้ id เดิม = dedupe+ย้ายกลุ่ม / `https://evil.com` = กล่อง error "ไม่อยู่ในรายการอนุญาต" / **D5:** ยังไม่ login เปิด `/annotate?fileUrl=…` → ได้ `/login?next=<path+query ครบ>` → login ผ่านแล้วกลับมาลงทะเบียนเข้า viewer + canvas render, session ครบ + เปิด `/login?next=//evil.com` → จบที่ `/` ของ app ไม่ออกนอกโฮสต์) + ทดสอบแล้วลบ row ทดสอบตาม `external_id` เป๊ะ
|
||||
- **Gap-analysis รอบ 2 (plan-reviewer 2026-09-22 เย็น — หลัง D5 + sync แผน):** D1–D5 ผ่านทุกข้อ · invariant #1/#4/#12 ไม่ถูกละเมิด · hardening ข้อ 1–2 และ 4 ยืนยันทำแล้ว ข้อ 3 ยังเป็น known risk ตามที่รับไว้ · แก้ตามรายงานแล้ว 2 ข้อ: **(4)** `firstSearchParam` ย้ายจาก `lib/external-import.ts` → `utils/external-file.ts` (pure — เดิม import จาก module ที่ลาก `db`/`fs` ตามเข้าหน้า login) และ **(5)** ตัวอักษร carve-out invariant #4 + รายการ `lib/external-import.ts` ในโครงสร้างไฟล์ของ CLAUDE.md ครบทั้ง entry หน้า `/` และ `/annotate` · **บันทึกไว้พิจารณา (ยังไม่แก้):** **(1)** `app/annotate/page.tsx` ไม่มี defense `getSessionUser` ซ้อน middleware เหมือนหน้า `/` และ viewer (ปลอดภัยตราบใดที่ matcher ของ middleware ยังจับ path นี้) **(2)** runtime error จาก DB ตอน `registerExternalFile` (connection ล่ม / rethrow หลัง 23505) ทะลุออกจาก render เป็น 500 ทั้งหน้า — โค้ดจัดการเฉพาะ validation fail (แยก catch ที่ไม่ใช่ `NEXT_REDIRECT` ได้) **(3)** `validateLocalPath` เช็ค `..` บนค่า raw ก่อน `decodeURIComponent` (`%2e%2e%2f` ผ่านการเช็ค — ไม่ใช่ช่องโหว่ที่ใช้งานได้เพราะ path guard ของ `resolvePublicPdfPath` ดักทับอยู่ แต่ลำดับที่ถูกคือ decode ก่อนแล้วเช็คบนค่า decoded)
|
||||
|
||||
## Out of scope ของ N8
|
||||
|
||||
- auth/credential ต่อ e-Court (ถ้า `admcasedey` ต้องมี session จริง ต้องกลับมาเพิ่มการส่งต่อ token/cookie)
|
||||
- ระบบหลายเวอร์ชันต่อเอกสาร + คัดลอก/สืบทอด annotation ข้ามเวอร์ชัน (id เปลี่ยน = ไฟล์ใหม่ ตามตารางด้านบน)
|
||||
- หน้าจัดการ/ลบ/แก้ชื่อไฟล์นำเข้าใน UI, แคชสำเนาไฟล์รีโมตลงดิสก์, ไฟล์รีโมตจากโฮสต์นอก allowlist
|
||||
- หน้าจัดการกลุ่ม/แก้ `typeId` ด้วยมือใน UI ของเรา (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9)
|
||||
- API ดึงข้อมูลกลับให้ e-Court (**N10** `docs/PLAN-API.md` — แผนแยก)
|
||||
|
|
@ -336,3 +336,28 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
- **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
|
||||
- **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม)
|
||||
- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10)
|
||||
|
||||
### N8 · เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` (2026-09-22 · implement แล้ว)
|
||||
|
||||
> แผนงานฉบับเต็ม: `docs/PLAN-ECOURT.md`
|
||||
|
||||
- **สัญญาลิงก์:** `fileUrl` จำเป็น + `fileId` แนะนำ (dedupe key `external_id = "ecourt:<fileId>"`, ไม่มี fileId fallback `"url:<href>"`) + `typeId` แนะนำ (รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน — ส่งต่อให้ **N9** `docs/PLAN-DOCSET.md` จัดกลุ่มตอนแสดงผล)
|
||||
- **ไม่เก็บสำเนาไฟล์ (metadata-only):** ลง DB แค่ `title` (จาก basename ของ URL) + `external_id` + URL เต็มใน `pdf_url` + `type_id` — ตอนเปิดดู/export server ดึงไฟล์สดจาก e-Court ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) — invariant #1 ครบ
|
||||
- **หลักเวอร์ชัน — id เปลี่ยน = ไฟล์ใหม่:** fileId เดิม + URL เปลี่ยน (Temp link อายุใหม่) = row เดิม annotation ครบ + refresh `pdf_url`; fileId ใหม่ = row ใหม่เริ่ม blank ไม่สืบทอด; ลิงก์ส่ง `typeId` มา = ย้ายกลุ่มตามค่าล่าสุด (ไม่ส่ง = คงเดิม ห้ามเคลียร์)
|
||||
- **Entry point 2 จุด:** หน้า `/` และ `/annotate` อ่าน query แล้ว `registerExternalFile` (`lib/external-import.ts` — dedupe + catch `23505` พร้อมกัน 2 แท็บ) → `redirect("/annotate/<uuid>")` — เรียกระหว่าง render ของ server component **ไม่ใช่ Server Action** (carve-out invariant #4: หน้า force-dynamic + redirect ออกทันที ไม่ต้อง publish event/`revalidatePath`) / fail = กล่อง error ไทย `role="alert"` เหนือ section เพิ่มเอกสาร หน้ารายการยังแสดง
|
||||
- **Validation (pure `utils/external-file.ts`):** allowlist โฮสต์ `admincourt.go.th` (suffix match กัน `admincourt.go.th.evil.com`/`xadmincourt.go.th`) + env `EXTERNAL_FILE_HOSTS` เพิ่มได้, `https:` เท่านั้น, ห้าม `user:pass@` — และรับ **path ในเครื่อง** (`/samples/…` `/uploads/…` — decode + ห้าม `..` + ตรวจไฟล์มีจริง) เพื่อทดสอบ grouping ใน dev + เติม `https://` ให้ URL ที่ไม่มี scheme
|
||||
- **ดึงไฟล์สด (`lib/remote-file.ts`):** `fetchSourceBytes` — validate ซ้ำชุดเต็มหลัง redirect (`res.url`), `Content-Length` pre-check + นับ byte ระหว่าง stream จำกัด **100MB**, timeout **60s**, guard body null/0 ไบต์ — ใช้ 2 จุด: `/api/pdf` และ `/api/annotations/export` (F8) ก่อนเข้า pipeline เดิม
|
||||
- **Viewer:** `withBasePath` ปล่อย absolute `http(s)://` ผ่านเฉย ๆ (invariant #12), PDF branch โหลดผ่าน `/api/pdf?fileId=` อยู่แล้ว, **รูปรีโมต (N3)** เปลี่ยนไปโหลดผ่าน `/api/pdf?fileId=` ด้วย (แยกทางด้วย `isHttpUrl`)
|
||||
- **Login วนกลับ (D5):** `middleware.ts` เดิมล้าง query ทิ้ง — แก้เป็นเก็บ `next = pathname + search` แนบไป `/login` (เฉพาะ `next !== "/"`) → หน้า login `sanitizeNextPath` (เฉพาะขึ้นต้น `/` และไม่ใช่ `//`/`/\` — กัน open-redirect) → มี session แล้ว `redirect(next ?? "/")` / login ผ่าน `LoginForm` `router.replace(next ?? "/")` — deep link ของคนยังไม่ login ไม่หาย
|
||||
- **การ์ดจำลองลิงก์:** `components/EcourtLinkForm.tsx` บนหน้ารายการ `/` (กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" → `router.push("/annotate?fileUrl=&fileId=&typeId=")`) + แสดงลิงก์เต็มสด ๆ — ทดสอบก่อนมีลิงก์จริงจาก e-Court
|
||||
- **Out of scope ของ N8:** auth/credential ต่อ e-Court (ส่งต่อ token/cookie ถ้าปลายทางต้องมี session จริง), ระบบหลายเวอร์ชัน + สืบทอด annotation ข้ามเวอร์ชัน, หน้าจัดการ/ลบ/แก้ `typeId` ด้วยมือใน UI (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9), แคชสำเนาไฟล์รีโมตลงดิสก์, โฮสต์นอก allowlist, API ดึงข้อมูลกลับให้ e-Court (**N10** `docs/PLAN-API.md`) — known risk ที่รับไว้: `/api/pdf` + export เป็น proxy ไม่ต้อง login (รู้ fileId = ดึง bytes ได้)
|
||||
|
||||
### N9 · กลุ่มเอกสารตาม `typeId` จาก e-Court + หน้า `/groups/[typeId]` (2026-09-23)
|
||||
|
||||
> แผนงานฉบับเต็ม: `docs/PLAN-DOCSET.md` — ต่อยอด `type_id` ที่ N8 เขียนตอน `registerExternalFile`
|
||||
|
||||
- **กลุ่มระดับเดียว กำหนดด้วย `files.type_id`** (จากลิงก์ e-Court `?typeId=` — เขียนที่ `registerExternalFile` ของ N8 เท่านั้น: insert ครั้งแรก + ย้ายกลุ่มเมื่อลิงก์รอบใหม่ส่ง `typeId` คนละค่า, ไม่ส่ง = คงเดิม ห้ามเคลียร์) — ไม่มีตารางกลุ่ม/many-to-many/หลายระดับ: กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query, แสดงผลด้วยรหัสกลุ่มโดยตรง (ไม่มีคอลัมน์ชื่อกลุ่ม — ถอด `type_name` ตามคำขอผู้ใช้) — ไฟล์ seed/อัปโหลด N4/ลิงก์ไม่มี typeId = NULL เสมอ
|
||||
- **หน้ารายการ `/` จัดกลุ่ม:** section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` asc เหมือนเดิม + ปุ่ม Annotate เดิม; หัวกลุ่ม = รหัสกลุ่ม + จำนวนไฟล์ + ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`; ไฟล์ `type_id` NULL → section **"ไม่มีกลุ่ม"** ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร — แสดงเสมอถ้ามีไฟล์) — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม ไม่มี client component/action ใหม่
|
||||
- **หน้า `/groups/[typeId]`** (server component `force-dynamic` — login ถูกบังคับโดย `middleware.ts` อยู่แล้ว): query `files where type_id` `orderBy createdAt` — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ `/` + กล่อง info **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม"** แสดงทุกครั้ง; รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม Annotate → `/annotate/{fileId}`; `generateMetadata` ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
|
||||
- **ไม่มี Server Action/SSE/migration ใหม่** (schema `type_id` + index มากับ N8 ใน migration เดียวกัน) — การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก `/` ทันที ไม่มี Router Cache ค้าง
|
||||
- **Out of scope ของ N9:** จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, bookmark รวมทั้งกลุ่ม (ครึ่งหลังของ gap 5.3.10 — ยังไม่ปิด), export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`)
|
||||
|
|
|
|||
|
|
@ -92,8 +92,12 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div
|
|||
| title | text NOT NULL | |
|
||||
| description | text | nullable |
|
||||
| pdf_url | text NOT NULL | |
|
||||
| external_id | text | nullable UNIQUE — N8 dedupe key เอกสารจากลิงก์ e-Court (`"ecourt:<fileId>"` / `"url:<href>"`); NULL = ไฟล์ seed/อัปโหลด |
|
||||
| type_id | text | nullable — N9 รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกันจากลิงก์ e-Court `?typeId=` (เขียนที่ `registerExternalFile` ของ N8 เท่านั้น); NULL = ไม่มีกลุ่ม |
|
||||
| created_at | timestamptz `defaultNow()` | |
|
||||
|
||||
Index: `files_type_id_idx` (type_id) · UNIQUE (external_id — unique บน nullable ยอมหลาย NULL)
|
||||
|
||||
### Enum `annotation_type` = `'HIGHLIGHT' | 'DRAW' | 'COMMENT'`
|
||||
|
||||
### ตาราง `annotations`
|
||||
|
|
|
|||
3
drizzle/0002_mushy_excalibur.sql
Normal file
3
drizzle/0002_mushy_excalibur.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "type_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "type_name" text;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "files_type_id_idx" ON "files" USING btree ("type_id");
|
||||
2
drizzle/0003_lively_stone_men.sql
Normal file
2
drizzle/0003_lively_stone_men.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "external_id" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "files_external_id_key" ON "files" USING btree ("external_id");
|
||||
1
drizzle/0004_right_ted_forrester.sql
Normal file
1
drizzle/0004_right_ted_forrester.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "files" DROP COLUMN IF EXISTS "type_name";--> statement-breakpoint
|
||||
503
drizzle/meta/0002_snapshot.json
Normal file
503
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
{
|
||||
"id": "4f6d3c6b-2b42-4245-8dc3-f2017c924cc2",
|
||||
"prevId": "8124204c-9872-4e64-9486-693d5c639ff6",
|
||||
"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.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
|
||||
},
|
||||
"type_id": {
|
||||
"name": "type_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type_name": {
|
||||
"name": "type_name",
|
||||
"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": {}
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
524
drizzle/meta/0003_snapshot.json
Normal file
524
drizzle/meta/0003_snapshot.json
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
{
|
||||
"id": "38578f9c-c990-4dfc-8e4d-8807680aec9c",
|
||||
"prevId": "4f6d3c6b-2b42-4245-8dc3-f2017c924cc2",
|
||||
"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.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
|
||||
},
|
||||
"type_name": {
|
||||
"name": "type_name",
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
518
drizzle/meta/0004_snapshot.json
Normal file
518
drizzle/meta/0004_snapshot.json
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
{
|
||||
"id": "84f979ad-4a95-41f5-8082-30a994f8644d",
|
||||
"prevId": "38578f9c-c990-4dfc-8e4d-8807680aec9c",
|
||||
"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.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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,27 @@
|
|||
"when": 1789544914887,
|
||||
"tag": "0001_bright_celestials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1790067045038,
|
||||
"tag": "0002_mushy_excalibur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1790069992426,
|
||||
"tag": "0003_lively_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1790072946313,
|
||||
"tag": "0004_right_ted_forrester",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
148
lib/external-import.ts
Normal file
148
lib/external-import.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// N8 (PLAN-ECOURT.md D1) — ลงทะเบียนเอกสารจากลิงก์ e-Court ลง DB แบบ metadata-only
|
||||
// (ไม่ดาวน์โหลด — เก็บแค่ title + external_id + URL ใน pdf_url; ตอนเปิดดู/export
|
||||
// ค่อยดึงไฟล์สดผ่าน lib/remote-file.ts)
|
||||
// server module ธรรมดา **ไม่ใช่ "use server"** — เรียกจาก server component ของหน้า "/"
|
||||
// ไม่เปิดเป็น endpoint ให้ client ยิงตรง ๆ
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import {
|
||||
externalIdFor,
|
||||
isHttpUrl,
|
||||
titleFromExternalUrl,
|
||||
validateExternalFileUrl,
|
||||
type ExternalFileUrlError,
|
||||
} from "@/utils/external-file";
|
||||
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
||||
|
||||
export type RegisterExternalFileResult =
|
||||
| { ok: true; fileId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const URL_ERROR_TEXT: Record<ExternalFileUrlError, string> = {
|
||||
invalid_url: "ลิงก์ไฟล์ไม่ถูกต้อง",
|
||||
not_https: "ลิงก์ไฟล์ต้องเป็น https เท่านั้น",
|
||||
has_credentials: "ลิงก์ไฟล์มีชื่อผู้ใช้/รหัสผ่านฝังอยู่ — ไม่รองรับ",
|
||||
host_not_allowed: "โฮสต์ของลิงก์ไฟล์ไม่อยู่ในรายการอนุญาต",
|
||||
};
|
||||
|
||||
/** รหัสกลุ่ม normalize: trim → ว่าง = ไม่มี, cap 200 ตัวอักษร (เทียบ title) */
|
||||
function normGroup(s: string | undefined): string | undefined {
|
||||
const v = (s ?? "").trim().slice(0, 200);
|
||||
return v || undefined;
|
||||
}
|
||||
|
||||
/** path ในเครื่อง (ทดสอบใน dev เช่น /samples/…): decode %20, ห้าม .., นามสกุลผ่าน
|
||||
* whitelist และต้องมีไฟล์จริงใน public/ ผ่าน path guard เดียวกับ viewer/export */
|
||||
function validateLocalPath(
|
||||
raw: string,
|
||||
): { ok: true; path: string; ext: string } | { ok: false; error: string } {
|
||||
if (raw.includes("..")) return { ok: false, error: "เส้นทางไฟล์ไม่ถูกต้อง" };
|
||||
let p = raw;
|
||||
try {
|
||||
p = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// ชื่อ encode เสีย — ใช้ค่าดิบ
|
||||
}
|
||||
const dot = p.lastIndexOf(".");
|
||||
const ext = dot > 0 ? p.slice(dot).toLowerCase() : "";
|
||||
if (!SAMPLE_FILE_EXTENSIONS.includes(ext)) {
|
||||
return { ok: false, error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}` };
|
||||
}
|
||||
try {
|
||||
readPublicPdf(p);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError || (err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return { ok: false, error: "ไม่พบไฟล์นี้ในระบบ (path ต้องอยู่ใน public/ เช่น /samples/…)" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, path: p, ext };
|
||||
}
|
||||
|
||||
export async function registerExternalFile(
|
||||
rawFileUrl: string,
|
||||
rawFileId?: string,
|
||||
rawTypeId?: string,
|
||||
): Promise<RegisterExternalFileResult> {
|
||||
let trimmed = rawFileUrl.trim();
|
||||
if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" };
|
||||
// URL ที่ไม่ได้ขึ้นต้น scheme (เช่น "www.rd.usda.gov/…") เติม https:// ให้ก่อน validate
|
||||
// — path ในเครื่อง (ขึ้นต้น "/") ไม่แตะ
|
||||
if (!trimmed.startsWith("/") && !/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const typeId = normGroup(rawTypeId);
|
||||
|
||||
// สองทาง: https ของ e-Court (ตามสัญญาลิงก์ — validate + allowlist) หรือ path ในเครื่อง
|
||||
// (ทดสอบ dev เช่น /samples/…) — ทั้งคู่เก็บลง pdf_url เหมือนกัน ต่างกันแค่ตอนเปิดดู
|
||||
// (รีโมตดึงสดผ่าน lib/remote-file.ts, ในเครื่องอ่านจาก public/)
|
||||
let sourceUrl: string;
|
||||
let title: string;
|
||||
let externalId: string;
|
||||
if (isHttpUrl(trimmed)) {
|
||||
const checked = validateExternalFileUrl(trimmed);
|
||||
if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] };
|
||||
sourceUrl = checked.url.href;
|
||||
title = titleFromExternalUrl(checked.url, rawFileId);
|
||||
externalId = externalIdFor(rawFileId, checked.url);
|
||||
} else if (trimmed.startsWith("/")) {
|
||||
const local = validateLocalPath(trimmed);
|
||||
if (!local.ok) return local;
|
||||
sourceUrl = local.path;
|
||||
const base = local.path.split("/").pop() ?? "";
|
||||
title =
|
||||
base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร";
|
||||
const id = rawFileId?.trim();
|
||||
externalId = id ? `ecourt:${id}` : `url:${local.path}`;
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ลิงก์ไฟล์ต้องเป็น https หรือ path ในระบบที่ขึ้นต้นด้วย / เช่น /samples/…",
|
||||
};
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, externalId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
// fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด
|
||||
// (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ)
|
||||
const patch: { pdfUrl?: string; typeId?: string } = {};
|
||||
if (existing.pdfUrl !== sourceUrl) patch.pdfUrl = sourceUrl;
|
||||
if (typeId && existing.typeId !== typeId) patch.typeId = typeId;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(files).set(patch).where(eq(files.id, existing.id));
|
||||
}
|
||||
return { ok: true, fileId: existing.id };
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
await db.insert(files).values({
|
||||
id,
|
||||
title,
|
||||
pdfUrl: sourceUrl,
|
||||
externalId,
|
||||
typeId: typeId ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
// 23505: เปิด 2 แท็บ URL/id เดียวกันครั้งแรกพร้อมกัน — คืน row ของฝ่ายที่ชนะไปก่อน
|
||||
if ((err as { code?: string })?.code === "23505") {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, externalId))
|
||||
.limit(1);
|
||||
if (row) return { ok: true, fileId: row.id };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, fileId: id };
|
||||
}
|
||||
61
lib/remote-file.ts
Normal file
61
lib/remote-file.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
|
||||
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
|
||||
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
|
||||
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
|
||||
|
||||
const MAX_REMOTE_BYTES = 100 * 1024 * 1024; // เท่า limit อัปโหลด N4
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class RemoteFileError extends Error {}
|
||||
|
||||
export function isRemoteSource(pdfUrl: string): boolean {
|
||||
return isHttpUrl(pdfUrl);
|
||||
}
|
||||
|
||||
export async function fetchSourceBytes(url: string): Promise<Buffer> {
|
||||
const checked = validateExternalFileUrl(url);
|
||||
if (!checked.ok) throw new RemoteFileError("ลิงก์ไฟล์รีโมตไม่ผ่านการตรวจสอบ");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(checked.url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
throw new RemoteFileError("ดึงไฟล์จากต้นทางไม่สำเร็จ (เครือข่ายล่มหรือหมดเวลา)");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new RemoteFileError(`ดึงไฟล์จากต้นทางไม่สำเร็จ (HTTP ${res.status})`);
|
||||
}
|
||||
// hardening (plan-reviewer): re-validate หลัง redirect ด้วยชุดเต็ม ไม่ใช่แค่ hostname
|
||||
// — กัน host ใน allowlist เด้งไป http:// หรือ host อื่นกลาง redirect chain
|
||||
const afterRedirect = validateExternalFileUrl(res.url);
|
||||
if (!afterRedirect.ok) {
|
||||
throw new RemoteFileError("ลิงก์ถูกเปลี่ยนทางไปปลายทางที่ไม่อนุญาต");
|
||||
}
|
||||
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) {
|
||||
throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB");
|
||||
}
|
||||
// hardening (plan-reviewer): ไม่มี body (เช่น HTTP 204) = ไฟล์เปล่า — ห้ามปล่อย
|
||||
// Buffer ว่างไหลไป pipeline กลาง ๆ
|
||||
if (!res.body) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_REMOTE_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB");
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
if (total === 0) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)");
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
|
@ -8,8 +8,13 @@ export function middleware(req: NextRequest) {
|
|||
const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value);
|
||||
if (!user) {
|
||||
const url = req.nextUrl.clone();
|
||||
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
|
||||
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /login มี query เกินจำเป็น
|
||||
// (path ตรงนี้ตัด basePath แล้ว / NextURL ใส่ basePath คืนตอน redirect เอง — invariant #12)
|
||||
const next = url.pathname + url.search;
|
||||
url.pathname = "/login";
|
||||
url.search = "";
|
||||
if (next !== "/") url.searchParams.set("next", next);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
|
|
|
|||
28
tests/utils/external-file.test.ts
Normal file
28
tests/utils/external-file.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeNextPath } from "@/utils/external-file";
|
||||
|
||||
// N8 D5 — path วนกลับหลัง login ต้องเป็น path ใน app เท่านั้น (กัน open-redirect)
|
||||
describe("sanitizeNextPath", () => {
|
||||
it("path ใน app (มี query ก็ได้) ผ่านตรง ๆ", () => {
|
||||
expect(sanitizeNextPath("/")).toBe("/");
|
||||
expect(sanitizeNextPath("/annotate")).toBe("/annotate");
|
||||
expect(sanitizeNextPath("/annotate/abc-123")).toBe("/annotate/abc-123");
|
||||
expect(sanitizeNextPath("/?fileUrl=https://x&fileId=1")).toBe(
|
||||
"/?fileUrl=https://x&fileId=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("absolute URL / protocol-relative / backslash → null กัน open-redirect", () => {
|
||||
expect(sanitizeNextPath("//evil.com")).toBeNull();
|
||||
expect(sanitizeNextPath("/\\evil.com")).toBeNull(); // browser ตีความ \ เป็น /
|
||||
expect(sanitizeNextPath("https://evil.com")).toBeNull();
|
||||
expect(sanitizeNextPath("http://evil.com/path")).toBeNull();
|
||||
expect(sanitizeNextPath("/\\//evil.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("ค่าว่าง/ไม่ขึ้นต้น / ขาด ๆ เกิน ๆ → null", () => {
|
||||
expect(sanitizeNextPath("")).toBeNull();
|
||||
expect(sanitizeNextPath("annotate")).toBeNull(); // ไม่มี / นำหน้า
|
||||
expect(sanitizeNextPath(" /annotate")).toBeNull(); // space นำหน้าไม่ผ่าน startsWith
|
||||
});
|
||||
});
|
||||
|
|
@ -3,5 +3,7 @@
|
|||
// plain <a href>/workerSrc/img src — ต่างจาก <Link>/router/redirect ของ next
|
||||
// ที่เติมให้อัตโนมัติ (pdfUrl ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น)
|
||||
export function withBasePath(path: string): string {
|
||||
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
|
||||
}
|
||||
|
|
|
|||
92
utils/external-file.ts
Normal file
92
utils/external-file.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// N8 (PLAN-ECOURT.md D2) — validation และตัวช่วยของลิงก์ไฟล์ภายนอก (pure module ไม่แตะ fs/db
|
||||
// เพื่อให้ middleware ใช้ร่วมได้ในอนาคต) + ตัวช่วยแยกไฟล์ในเครื่องกับไฟล์รีโมต
|
||||
|
||||
export type ExternalFileUrlError =
|
||||
| "invalid_url"
|
||||
| "not_https"
|
||||
| "has_credentials"
|
||||
| "host_not_allowed";
|
||||
|
||||
/** Next 14: searchParams เป็น plain object ค่า string | string[] — เอาตัวแรกของแต่ละ key
|
||||
* (ใช้ร่วมระหว่าง entry point หน้า "/", "/annotate" และหน้า login — วางที่ pure module
|
||||
* เพื่อไม่ลาก db/fs ของ lib/external-import ตามเข้าหน้าที่ไม่แตะ DB) */
|
||||
export function firstSearchParam(v: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
||||
/** allowlist โฮสต์รีโมต — default "admincourt.go.th" ครอบทั้ง admcasedey. และ judgement-api.
|
||||
* (suffix match), override ด้วย env EXTERNAL_FILE_HOSTS (comma-separated) —
|
||||
* อ่านตอน call ไม่ใช่ตอน module load เพื่อ test ได้ */
|
||||
export function externalHosts(): string[] {
|
||||
const raw = process.env.EXTERNAL_FILE_HOSTS;
|
||||
if (!raw) return ["admincourt.go.th"];
|
||||
const list = raw
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return list.length > 0 ? list : ["admincourt.go.th"];
|
||||
}
|
||||
|
||||
/** suffix match กันโฮสต์ปลอม: host === entry หรือ host.endsWith("." + entry)
|
||||
* (ไม่ match "admincourt.go.th.evil.com" และ "xadmincourt.go.th") */
|
||||
export function isAllowedExternalHost(host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
return externalHosts().some((entry) => h === entry || h.endsWith("." + entry));
|
||||
}
|
||||
|
||||
export function validateExternalFileUrl(
|
||||
raw: string,
|
||||
): { ok: true; url: URL } | { ok: false; error: ExternalFileUrlError } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return { ok: false, error: "invalid_url" };
|
||||
}
|
||||
if (url.protocol !== "https:") return { ok: false, error: "not_https" };
|
||||
if (url.username || url.password) {
|
||||
return { ok: false, error: "has_credentials" };
|
||||
}
|
||||
if (!isAllowedExternalHost(url.hostname)) {
|
||||
return { ok: false, error: "host_not_allowed" };
|
||||
}
|
||||
return { ok: true, url };
|
||||
}
|
||||
|
||||
/** absolute http(s):// หรือไม่ — ใช้ทั้งฝั่ง server แยกว่า pdf_url เป็นรีโมต
|
||||
* และฝั่ง client เลือกทางโหลดรูปภาพ */
|
||||
export function isHttpUrl(s: string): boolean {
|
||||
return /^https?:\/\//i.test(s.trim());
|
||||
}
|
||||
|
||||
/** title จาก basename ของ URL (decode แล้ว ตัดนามสกุล cap 200) —
|
||||
* path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ fallbackId หรือชื่อกลาง */
|
||||
export function titleFromExternalUrl(url: URL, fallbackId?: string): string {
|
||||
const base = url.pathname.split("/").pop() ?? "";
|
||||
let name = base;
|
||||
try {
|
||||
name = decodeURIComponent(base);
|
||||
} catch {
|
||||
// ชื่อ encode เสีย — ใช้ base ดิบ
|
||||
}
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot > 0) name = name.slice(0, dot);
|
||||
name = name.trim().slice(0, 200);
|
||||
return name || fallbackId?.trim() || "เอกสารจาก e-Court";
|
||||
}
|
||||
|
||||
/** dedupe key ของเอกสารนำเข้า — "ecourt:<fileId>" เมื่อมี id ของ e-Court,
|
||||
* ไม่มี fallback เป็น "url:<href>" (id เปลี่ยน = ไฟล์ใหม่ ตามหลักของแผน) */
|
||||
export function externalIdFor(fileId: string | undefined, url: URL): string {
|
||||
const id = fileId?.trim();
|
||||
return id ? `ecourt:${id}` : `url:${url.href}`;
|
||||
}
|
||||
|
||||
/** path วนกลับหลัง login (N8 D5) — ต้องขึ้นต้น "/" และไม่ใช่ "//" หรือ "/\"
|
||||
* (กัน open-redirect: browser ตีความ "//evil.com" และ "/\evil.com" เป็น absolute
|
||||
* URL ไป host อื่น) — ค่า next มาจาก query ที่ใครก็แก้ได้ ต้อง sanitize ก่อนใช้เสมอ;
|
||||
* ไม่ผ่านคืน null ให้ caller ใช้ "/" */
|
||||
export function sanitizeNextPath(s: string): string | null {
|
||||
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null;
|
||||
return s;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue