Compare commits

...

3 commits

Author SHA1 Message Date
Your Name
6940b3870d docs: ร่างแผน N8 ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด — ปิด gap 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร"): ตาราง document_sets + files.set_id nullable (ไฟล์หนึ่งอยู่ชุดเดียว onDelete set null — ลบชุดไฟล์ไม่หาย คนละเรื่องกับ CASCADE ของ annotations) + หน้าใหม่ /sets/[id] bookmark รวมทุกไฟล์ของชุดคลิกกระโดดผ่าน deep link F12 + จัดการชุดบนหน้ารายการ (สร้าง/ย้ายเข้า-ออก/แก้ชื่อ/ลบ — ทุกคนที่ login จัดการได้ roles ยัง out of scope ตาม 5.3.23) — ยังไม่ implement (เสนอ 2026-09-17 รอ /implement-feat N8 ตาม governance: ต้องเขียนสเปกลง PLAN-FEAT.md §7 + ขยาย whitelist ใน CLAUDE.md ก่อน)
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 41s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:43:16 +07:00
Your Name
55d09ce5c3 N7 ส่งออกรายการ bookmark เป็น JSON แทนรายงาน PDF (เปลี่ยน deliverable ตามคำขอผู้ใช้ 2026-09-17) — route /api/bookmarks/export เขียนใหม่จาก PDFDocument.create ทั้งชุด (ฟอนต์ Sarabun/layout A4/แบ่งหน้า/ต้นไม้ indent 3 ระดับ รวม ~190 บรรทัด) เป็น JSON ข้อมูลล้วนจาก DB: validate fileId (UUID → 400) + ไม่พบไฟล์ = 404 → query bookmarks ทั้งไฟล์ orderBy pageIndex → yOffset → createdAt (ลำดับ N5 เดียวกับ sidebar) → envelope { fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] } ผ่าน toBookmarkDTO ตรง ๆ (invariant #5) เก็บ parentId ครบ lossless (reconstruct ต้นไม้จากข้อมูลเองได้) จึงถอด build tree/cycle guard/ฟอนต์/layout ทิ้งหมด; pretty-print 2 space + content-type application/json; charset=utf-8 + attachment filename* จาก encodeRFC5987("{title}-bookmarks.json") (pattern F8/F16); 0 bookmark = "bookmarks": [] ไม่ 404 (ปุ่มทำงานเสมอ); ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1) + read-only ไม่ publish event ไม่ revalidatePath + ปุ่มท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" เปลี่ยนไอคอน Download → Braces ({ }) พร้อม title/aria-label "เป็น JSON" (ยังเป็นลิงก์ธรรมดา pattern Export CSV F16 — ส่งออกทุก bookmark ของไฟล์เสมอ ไม่ผูกเงื่อนไขค้นหา N6) + PLAN-FEAT.md §7 N7 เขียนสเปกใหม่เป็น JSON + PLAN-BOOKMARK-PDF.md ติด note หัวไฟล์ว่าโค้ด PDF ในแผนเป็น historical record (สเปกปัจจุบันอยู่ PLAN-FEAT) + gap-analysis ปิด gap กลุ่ม B 5.3.11 ✅ (N5) 5.3.12 ✅ (N6) 5.3.14 ✅ (API นี้) 5.3.15 ✅ (export PDF ของ F8 ฝัง bookmark เป็น outline ติดไปกับไฟล์) พร้อม header อัปเดตล่าสุด 2026-09-17 — ไฟล์เดียวกันมี 5.3.19/5.3.22 ✅ mock login/5.3.23 ของงาน login ตามไปด้วย
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:43:00 +07:00
Your Name
65b72cf817 mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
22 changed files with 592 additions and 351 deletions

View file

@ -13,7 +13,7 @@
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules)
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
- Tailwind CSS
- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`)
- mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
@ -34,26 +34,30 @@
```
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/)
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)
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
utils/user.ts ← anonymous identity (localStorage)
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route)
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie)
app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4)
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ)
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/login/page.tsx ← หน้า login (มี session แล้ว redirect /)
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
@ -62,13 +66,15 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18)
components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4)
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`)
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (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 เดิม)
- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
- ไม่มี 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)
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
- หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`)

View file

@ -12,7 +12,7 @@
- **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม
- **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด)
- **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น
- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง)
- หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง)
## สิ่งที่ต้องมี (Prerequisites)
@ -85,7 +85,7 @@ npm run dev
| bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน |
| zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) |
Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`)
Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login)
ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh
@ -112,7 +112,7 @@ db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
db/index.ts postgres.js + drizzle (prepare: false)
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
utils/user.ts anonymous identity (localStorage)
utils/user.ts parse/build cookie oacCase (mock login e-Court)
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)

41
app/actions/auth.ts Normal file
View file

@ -0,0 +1,41 @@
"use server";
// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234
// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts
// — บังคับด้วย middleware.ts)
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user";
const MOCK_PASSWORD = "Demo1234";
export async function login(
username: string,
password: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
// mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…"
await new Promise((resolve) => setTimeout(resolve, 500));
const name = username.trim();
// username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft
if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) {
return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" };
}
// u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย
// userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว
cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 8 * 60 * 60,
});
return { ok: true };
}
export async function logout(): Promise<void> {
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
redirect("/login");
}

View file

@ -1,5 +1,5 @@
import { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import {
@ -13,6 +13,7 @@ import {
} from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
import { getSessionUser } from "@/lib/session";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -27,6 +28,10 @@ export default async function AnnotatePage({
const { id } = params;
if (!UUID_RE.test(id)) notFound();
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
const user = getSessionUser();
if (!user) redirect("/login");
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
@ -62,6 +67,7 @@ export default async function AnnotatePage({
initialAnnotations={annotationRows.map(toAnnotationDTO)}
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
initialReplies={replyRows.map(toReplyDTO)}
user={user}
/>
</>
);

View file

@ -1,38 +1,19 @@
import fs from "node:fs";
import path from "node:path";
import fontkit from "@pdf-lib/fontkit";
import { PDFDocument, rgb, type PDFFont, type PDFPage } from "pdf-lib";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { bookmarks, files, type BookmarkRow } from "@/db/schema";
import { encodeRFC5987, wrapTextByWidth } from "@/utils/pdf-export";
import { bookmarks, files, toBookmarkDTO } from "@/db/schema";
import { encodeRFC5987 } from "@/utils/pdf-export";
// รายงานรายการ bookmark เป็น PDF (N7 — PLAN-BOOKMARK-PDF.md / PLAN-FEAT.md §7, requirement 5.3.15)
// - สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create) — ไม่โหลดไฟล์เอกสารต้นฉบับเลย
// (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป จึงไม่เกี่ยว readPublicPdf)
// - ต้นไม้ indent 3 ระดับ เรียงตามลำดับหน้า (N5) เหมือน sidebar — แต่ละรายการ:
// ชื่อ + "หน้า N · ผู้บันทึก · วันที่บันทึก" + โน้ตใต้ชื่อ
// ส่งออกรายการ bookmark เป็น JSON (N7 — เดิมเป็นรายงาน PDF ตาม requirement 5.3.15
// เปลี่ยนตามคำขอผู้ใช้ 2026-09-17)
// - ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1)
// - array flat เก็บ parentId ครบ (lossless) เรียงตามลำดับ N5 เหมือน sidebar
// ต้นไม้ reconstruct จาก parentId ได้เอง จึงไม่ต้อง build tree/cycle guard
// - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
// ─── layout คงที่ของรายงาน (A4 portrait) ───
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN = 48;
const INDENT_STEP = 14; // indent ต่อระดับของต้นไม้
const META_SIZE = 9;
const LINE_H = 1.35; // พื้นที่เผื่อสระบน–ล่างของไทย (ไ ใ / ุ ึ)
// slate-900 / slate-500 / slate-200 — โทนเดียวกับ sidebar
const INK = rgb(15 / 255, 23 / 255, 42 / 255);
const MUTED = rgb(100 / 255, 116 / 255, 139 / 255);
const RULE = rgb(226 / 255, 232 / 255, 240 / 255);
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
@ -52,173 +33,20 @@ export async function GET(req: Request) {
asc(bookmarks.createdAt),
);
// ฟอนต์จำเป็น — รายงานเป็นข้อความล้วน ไม่มี fallback ที่มีความหมาย
// (ต่างจาก D5 ของ F8 ที่ annotation ยังวาดรูปได้จึง optional)
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
if (!fs.existsSync(fontPath)) {
console.error("ไม่พบ public/fonts/Sarabun-Regular.ttf");
return new Response("ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf", { status: 500 });
}
const doc = await PDFDocument.create();
doc.registerFontkit(fontkit);
let font: PDFFont;
try {
font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun ไม่สำเร็จ (รายงาน bookmark)", err);
return new Response("ฝังฟอนต์ไม่สำเร็จ สร้างรายงานไม่ได้", { status: 500 });
}
const widthOf = (s: string, size: number) => font.widthOfTextAtSize(s, size);
// ต้นไม้ตาม parentId — เดินตามลำดับ array ที่ query เรียงตาม N5 มาแล้ว
// (ลูกในแต่ละแม่เรียงตาม หน้า → yOffset → เวลา อัตโนมัติ)
// orphan (parentId ไม่อยู่ในชุด) → เลื่อนขึ้น root เหมือน sidebar/buildOutline
const ids = new Set(rows.map((b) => b.id));
const childrenOf = new Map<string, BookmarkRow[]>();
const roots: BookmarkRow[] = [];
for (const b of rows) {
if (b.parentId && ids.has(b.parentId)) {
const list = childrenOf.get(b.parentId) ?? [];
list.push(b);
childrenOf.set(b.parentId, list);
} else {
roots.push(b);
}
}
type Item = { row: BookmarkRow; level: number };
const items: Item[] = [];
// visited กัน DFS วนไม่รู้จบเมื่อข้อมูลเป็นวงจร (defensive — action กันมาแล้ว)
// ใช้ seen ชุดเดียวร่วมทุก root: แชร์ subtree ข้าม root ไม่เกิด (ลูกมีแม่เดียว)
// เกิดจริงเฉพาะวงจร ซึ่งต้องกันไม่ให้เดินซ้ำ
const seen = new Set<string>();
const walk = (b: BookmarkRow, level: number): void => {
if (seen.has(b.id)) return;
seen.add(b.id);
items.push({ row: b, level });
for (const k of childrenOf.get(b.id) ?? []) walk(k, level + 1);
};
for (const r of roots) walk(r, 1);
// cycle (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action) → โหนดในวงจรไม่มี root เข้าถึง
// เติมเป็นรายการระดับ 1 ให้ครบทุกแถว "ข้อมูลไม่หาย" (ต่างจาก buildOutline ที่ drop
// เงียบ ๆ — รายงานหายไปเฉย ๆ ยิ่งงงกว่า) — เคส roots ว่างทั้งไฟล์ (วงจรล้วน) ก็
// ออกมาแบนทั้งหมดระดับ 1 จากกลไกนี้เอง
for (const row of rows) {
if (!seen.has(row.id)) items.push({ row, level: 1 });
}
// ─── วาด — y คือ "ขอบล่างของกล่องบรรทัดถัดไป" (baseline = y + ส่วนต่างของ line height)
let page: PDFPage = doc.addPage([PAGE_W, PAGE_H]);
let y = PAGE_H - MARGIN;
// เช็คพื้นที่*ต่อบรรทัด* ไม่ใช่ต่อรายการ — บรรทัดเดียวพอกับหน้าเปล่าเสมอ จึงไม่มี
// เคสแถวสูงเกินหน้าที่ต้องจัดการพิเศษ (โน้ตยาว ๆ ก็ไหลข้ามหน้าเอง)
const advanceLine = (size: number): number => {
const lh = size * LINE_H;
if (y - lh < MARGIN) {
page = doc.addPage([PAGE_W, PAGE_H]);
y = PAGE_H - MARGIN;
}
y -= lh;
return y + (lh - size); // baseline ชิดล่างของกล่องบรรทัด (เผื่อ descender ไทย)
};
const drawTextLine = (
text: string,
x: number,
size: number,
color = INK,
): void => {
// ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง — ถ้าฝัง advanceLine() ใน argument
// base ของ method call (`page`) ถูก resolve ก่อน arguments ตาม spec
// ทำให้บรรทัดแรกของหน้าใหม่ (ที่ advanceLine เพิ่ง reassign page) ถูกวาด
// กลับลงหน้าเก่าซ้อนบรรทัดบนสุด
const ty = advanceLine(size);
page.drawText(text, { x, y: ty, size, font, color });
// item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5)
const payload = {
fileId: file.id,
title: file.title,
exportedAt: new Date().toISOString(),
bookmarks: rows.map(toBookmarkDTO),
};
// หัวรายงาน (หน้าแรกหน้าเดียว) — ชื่อไฟล์ wrap เหมือนรายการ (title ใน DB ยาวได้ถึง 200)
drawTextLine("รายการที่คั่นหน้า", MARGIN, 16);
for (const line of wrapTextByWidth(file.title, PAGE_W - MARGIN * 2, (s) =>
widthOf(s, 12),
)) {
drawTextLine(line, MARGIN, 12);
}
const created = new Date().toLocaleDateString("th-TH", {
timeZone: "Asia/Bangkok",
});
drawTextLine(
`สร้างเมื่อ ${created} · ${rows.length} รายการ`,
MARGIN,
META_SIZE,
MUTED,
);
y -= 8;
page.drawLine({
start: { x: MARGIN, y },
end: { x: PAGE_W - MARGIN, y },
thickness: 1,
color: RULE,
});
y -= 18;
if (items.length === 0) {
drawTextLine("ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้", MARGIN, 11, MUTED);
}
let first = true;
for (const { row, level } of items) {
// เว้นช่องว่างก่อนรายการ — หัวข้อระดับ 1 เว้นกว้างกว่าให้เห็นกลุ่ม
// (y ตกหลุดขอบล่างจากการเว้น = advanceLine บรรทัดแรกเปิดหน้าใหม่เอง)
y -= first ? 0 : level === 1 ? 12 : 8;
first = false;
const indent = (level - 1) * INDENT_STEP;
const x = MARGIN + indent;
const maxW = PAGE_W - MARGIN * 2 - indent;
const titleSize = level === 1 ? 13 : 11;
for (const line of wrapTextByWidth(row.title, maxW, (s) =>
widthOf(s, titleSize),
)) {
drawTextLine(line, x, titleSize);
}
const date = row.createdAt.toLocaleDateString("th-TH", {
timeZone: "Asia/Bangkok",
});
drawTextLine(
`หน้า ${row.pageIndex + 1} · ${row.userName || "ไม่ทราบชื่อ"} · ${date}`,
x,
META_SIZE,
MUTED,
);
if (row.note) {
for (const line of wrapTextByWidth(`โน้ต: ${row.note}`, maxW, (s) =>
widthOf(s, META_SIZE),
)) {
drawTextLine(line, x, META_SIZE, MUTED);
}
}
}
// footer เลขหน้า — วาดท้ายเมื่อรู้จำนวนหน้าทั้งหมด (เนื้อหาหยุดที่ ≥ MARGIN ไม่ชน 24pt)
const pages = doc.getPages();
pages.forEach((p, i) => {
const label = `หน้า ${i + 1} จาก ${pages.length}`;
p.drawText(label, {
x: (PAGE_W - widthOf(label, META_SIZE)) / 2,
y: 24,
size: META_SIZE,
font,
color: MUTED,
});
});
const out = await doc.save();
const filename = encodeRFC5987(`${file.title}-bookmarks.pdf`);
return new Response(new Uint8Array(out), {
const filename = encodeRFC5987(`${file.title}-bookmarks.json`);
return new Response(JSON.stringify(payload, null, 2) + "\n", {
headers: {
"content-type": "application/pdf",
"content-type": "application/json; charset=utf-8",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16)
"content-disposition": `attachment; filename="bookmarks.pdf"; filename*=UTF-8''${filename}`,
"content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`,
},
});
}

21
app/login/page.tsx Normal file
View file

@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import LoginForm from "@/components/LoginForm";
import { getSessionUser } from "@/lib/session";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" };
export default function LoginPage() {
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
const user = getSessionUser();
if (user) redirect("/");
return (
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<LoginForm />
</main>
);
}

View file

@ -1,12 +1,14 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
import { FileImage, FileText } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
import UserPicker from "@/components/UserPicker";
import SessionBar from "@/components/SessionBar";
import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { getSessionUser } from "@/lib/session";
export const dynamic = "force-dynamic";
@ -25,6 +27,10 @@ function kindBadge(pdfUrl: string): string {
}
export default async function HomePage() {
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/login");
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
return (
@ -43,7 +49,7 @@ export default async function HomePage() {
</div>
</header>
<UserPicker />
<SessionBar userName={user.userName} />
<section className="mt-8">
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>

132
components/LoginForm.tsx Normal file
View file

@ -0,0 +1,132 @@
"use client";
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/app/actions/auth";
import Select from "@/components/Select";
import { MOCK_USERS } from "@/utils/user";
// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad
const INPUT_CLASS =
"mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30";
// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน)
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() {
const router = useRouter();
const passwordRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const usernameInput = form.elements.namedItem("username") as HTMLSelectElement;
const passwordInput = form.elements.namedItem("password") as HTMLInputElement;
startTransition(async () => {
try {
const result = await login(usernameInput.value, passwordInput.value);
if (result.ok) {
router.replace("/");
} else {
setError(result.error);
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
passwordInput.value = "";
passwordRef.current?.focus();
passwordRef.current?.select();
}
} catch {
setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
{/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div>
<p className="text-lg font-bold leading-tight text-primary">Judge&apos;s Note</p>
<p className="text-xs text-slate-500">ระบบ PDF Annotation สำหรับ e-Court</p>
</div>
</div>
<h1 className="mt-6 text-lg font-semibold text-slate-900">เข้าสู่ระบบ</h1>
<form onSubmit={onSubmit} className="mt-4">
<div>
<label htmlFor="username" className="text-sm font-semibold text-slate-700">
ชื่อผู้ใช้
</label>
<div className="mt-1">
<Select
id="username"
name="username"
autoComplete="username"
required
autoFocus
disabled={pending}
defaultValue=""
className={SELECT_CLASS}
>
{/* placeholder บังคับเลือก — ไม่ preselect คนแรก กันผู้ทดสอบ login ผิดคนโดยไม่ตั้งใจ */}
<option value="" disabled>
เลือกผู้ใช้
</option>
{MOCK_USERS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</Select>
</div>
</div>
<div className="mt-4">
<label htmlFor="password" className="text-sm font-semibold text-slate-700">
รหัสผ่าน
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={pending}
aria-invalid={!!error || undefined}
ref={passwordRef}
className={INPUT_CLASS}
/>
</div>
<button
type="submit"
disabled={pending}
className="mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
</button>
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */}
<p className="mt-5 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
รหัสผ่านทดสอบ:{" "}
<span className="font-semibold text-slate-700">Demo1234</span>
</p>
{error && (
<p role="alert" className="mt-3 text-xs text-red-600">
{error}
</p>
)}
</form>
</div>
);
}

View file

@ -0,0 +1,29 @@
"use client";
// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง
import { useTransition } from "react";
import { LogOut } from "lucide-react";
import { logout } from "@/app/actions/auth";
export default function LogoutButton() {
const [pending, startTransition] = useTransition();
const onLogout = () => {
startTransition(async () => {
await logout();
});
};
return (
<button
type="button"
onClick={onLogout}
disabled={pending}
className="flex shrink-0 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:opacity-50"
>
<LogOut className="h-3.5 w-3.5" aria-hidden />
{pending ? "กำลังออก…" : "ออกจากระบบ"}
</button>
);
}

15
components/SessionBar.tsx Normal file
View file

@ -0,0 +1,15 @@
// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component
// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้)
import LogoutButton from "./LogoutButton";
export default function SessionBar({ userName }: { userName: string }) {
return (
<div className="mt-6 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="min-w-0 text-sm text-slate-600">
เข้าสู่ระบบเป็น{" "}
<span className="font-semibold text-slate-900">{userName}</span>
</p>
<LogoutButton />
</div>
);
}

View file

@ -1,66 +0,0 @@
"use client";
// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม
// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร)
import { useEffect, useState } from "react";
import Select from "./Select";
import {
MOCK_USERS,
getStoredLocalUser,
setLocalUser,
type LocalUser,
} from "@/utils/user";
export default function UserPicker() {
const [current, setCurrent] = useState<LocalUser | null>(null);
// ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน
useEffect(() => {
const stored = getStoredLocalUser();
if (!stored) {
// ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก
setLocalUser(MOCK_USERS[0]);
setCurrent(MOCK_USERS[0]);
return;
}
setCurrent(stored);
}, []);
// identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร
const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId);
const select = (userId: string) => {
const user = MOCK_USERS.find((u) => u.userId === userId);
if (!user) return;
setLocalUser(user);
setCurrent(user);
};
return (
<section className="mt-6 rounded-lg border bg-white p-4 shadow-sm">
<h2 className="text-sm font-semibold text-primary">เลือกผู้ใช้ (mock)</h2>
<p className="mt-0.5 text-xs text-slate-400">
annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก
</p>
<Select
value={current ? (isMock ? current.userId : "") : MOCK_USERS[0].userId}
onChange={(e) => {
if (e.target.value) select(e.target.value);
}}
className="mt-3 block w-full rounded-md border bg-white py-2 pl-3 text-sm"
>
{current && !isMock && (
<option value="" disabled>
{current.userName} (สุ่มอัตโนมัติ)
</option>
)}
{MOCK_USERS.map((u) => (
<option key={u.userId} value={u.userId}>
{u.userName}
</option>
))}
</Select>
</section>
);
}

View file

@ -113,7 +113,7 @@ import {
type Rect,
type Size,
} from "@/utils/coordinates";
import { getLocalUser, type LocalUser } from "@/utils/user";
import type { OacUser } from "@/utils/user";
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
import { decodeTiffPages } from "@/utils/tiff-client";
import {
@ -1138,11 +1138,14 @@ export default function PDFViewer({
initialAnnotations,
initialBookmarks,
initialReplies,
user,
}: {
file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[];
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
user: OacUser;
}) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
@ -1194,7 +1197,6 @@ export default function PDFViewer({
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null);
const [scale, setScale] = useState(1);
const [user, setUser] = useState<LocalUser | null>(null);
const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
@ -1323,11 +1325,10 @@ export default function PDFViewer({
[replies, visibleAnnotations],
);
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
// (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect)
useEffect(() => {
setUser(getLocalUser());
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
}, []);

View file

@ -5,13 +5,13 @@ import { createPortal } from "react-dom";
import {
ArrowRight,
Bookmark,
Braces,
Check,
ChevronDown,
ChevronRight,
Circle,
Clock,
Copy,
Download,
EyeOff,
Highlighter,
MessageSquare,
@ -1114,15 +1114,15 @@ export default function AnnotationSidebar({
<ChevronRight className="h-3 w-3 shrink-0" />
)}
</button>
{/* N7: ส่งออกรายการ bookmark เป็น PDF — ลิงก์ธรรมดา pattern Export CSV (F16)
{/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16)
ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */}
<a
href={`/api/bookmarks/export?fileId=${fileId}`}
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF"
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF"
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
>
<Download className="h-3.5 w-3.5" />
<Braces className="h-3.5 w-3.5" />
</a>
</div>
{bmShowConditions && (

View file

@ -1,5 +1,7 @@
# แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)
> **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด)
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก
> อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅)
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N7** รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน `/implement-feat N7`

130
docs/PLAN-DOCSET.md Normal file
View file

@ -0,0 +1,130 @@
# แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8)
> สถานะ: **ร่างแผน (ยังไม่ 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`
## 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)
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)
## 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
```
- ตาราง `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/` เท่านั้น
### D2 — Server Actions (`app/actions/sets.ts`)
`"use server"` + validate ก่อนทุกครั้ง (pattern `app/actions/files.ts`):
- `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 — หน้ารายการ `/` จัดกลุ่มตามชุด
- 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)
### D4 — หน้า `/sets/[id]` — bookmark รวมทั้งชุด (`app/sets/[id]/page.tsx`)
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)
## 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/requirement/gap-analysis.md` — ปรับ 5.3.10 ⚠️ → ✊ **หลัง implement เสร็จเท่านั้น** (task ปิดงาน) ไม่ใช่ตอนนี้
- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด)
## ลำดับ 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 → ✅ |
## 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
## 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)
## Verification (ภาพรวม end-to-end)
1. `npm run dev` → login → หน้า `/`: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด
2. หัวชุด → "bookmark ของชุด" → `/sets/[id]`: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก
3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้น**ถูกหน้า**
4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย
5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม

View file

@ -326,15 +326,13 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
- **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5
- **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer
- **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนรายงาน PDF ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
### N7 · รายงานรายการ bookmark เป็น PDF (2026-09-16)
### N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17)
> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย)
> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()` — ไม่โหลดไฟล์เอกสารต้นฉบับเลย: invariant #1 โดยการสร้าง ไม่ใช่การก๊อป ไม่เกี่ยว `readPublicPdf`): หัวรายงาน (หน้าแรกหน้าเดียว: "รายการที่คั่นหน้า" + ชื่อไฟล์ + "สร้างเมื่อ {วันที่} · N รายการ" + เส้นคั่น) + **ต้นไม้ indent 3 ระดับ** (indent 14pt/ระดับ; ระดับ 1 = 13pt, 2–3 = 11pt — Regular ตัวเดียว ลำดับชั้นด้วย indent+ขนาด ไม่ใช้ตัวหนา) เรียงตาม N5 (`pageIndex → yOffset → createdAt` — query เรียงมาเลย ลูกเรียงตามเกณฑ์เดียวกันอัตโนมัติ) แต่ละรายการ: ชื่อ (wrap `wrapTextByWidth`) + metadata 9pt เทา "หน้า N · {userName || "ไม่ทราบชื่อ"} · {วันที่บันทึก `toLocaleDateString("th-TH")`}" + โน้ต "โน้ต: …" ใต้ metadata; A4 + แบ่งหน้าต่อบรรทัด (guard แถวสูงเกินหน้า) + footer "หน้า n จาก N" ทุกหน้า; embed `Sarabun-Regular.ttf` **จำเป็น** (รายงานเป็นข้อความล้วน — ไม่มีไฟล์ = 500 ข้อความชัด ต่างจาก D5 ของ F8 ที่ optional); 0 bookmark = PDF หน้าเดียวข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ); response `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.pdf")` (pattern F8/F16)
- **ต้นไม้ defensive:** orphan `parentId` → เลื่อนขึ้น root เหมือน sidebar/buildOutline; cycle (ข้อมูลแก้ตรง DB หลุด guard ของ action) → DFS ด้วย **seen ชุดเดียวร่วมทุก root** กันเดินซ้ำ/วนไม่รู้จบ แล้ว**เติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ** (ข้อมูลไม่หาย — รวมเคสวงจรปน root ปกติ และเคสวงจรล้วนทั้งไฟล์ = ออกมาแบนทั้งหมดระดับ 1 จากกลไกเดียวกัน)
- **การวาด (ระวัง 2 จุด):** วาดบรรทัดต้อง **advanceLine ก่อนแล้วอ่าน `page` ทีหลัง** — ฝัง `advanceLine()` ใน argument ของ `page.drawText(...)` แล้ว base ของ method call ถูก resolve ก่อน arguments ตาม spec ทำให้บรรทัดแรกของหน้าใหม่ (ที่เพิ่ง reassign `page`) ถูกวาดกลับลง**หน้าเก่า**ซ้อนบรรทัดบนสุด; **ชื่อไฟล์ในหัวรายงาน wrap ด้วย `wrapTextByWidth`** เหมือนรายการ (title ใน DB ยาวได้ถึง 200 ตัวอักษร)
- **UI:** ปุ่มไอคอน `Download` ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — **ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย** (invariant #1): validate `fileId` (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ sidebar/N5) → response JSON `{ fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] }` — item เป็น `toBookmarkDTO` ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ `parentId` ครบ **lossless** reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = `"bookmarks": []` (ไม่ 404 — ปุ่มทำงานเสมอ); `content-type: application/json; charset=utf-8` + `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.json")` (pattern F8/F16)
- **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, ตัวหนา (`Sarabun-Bold.ttf`), export ข้ามหลายไฟล์ (gap 5.3.10), ตัวเลือกหน้ากระดาษ/แนวนอน/config
- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10)

View file

@ -11,7 +11,7 @@
| A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว |
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict |
| A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 |
| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง |
| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) |
| A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/<uuid>.<ext>` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) |
| A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ |
| A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime |
@ -102,7 +102,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div
| id | uuid PK | |
| file_id | uuid FK → files ON DELETE CASCADE | |
| page_index | integer NOT NULL | 0-based |
| user_id | text NOT NULL | anonymous id จาก localStorage |
| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) |
| user_name | text NOT NULL | |
| type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | **strict typed union** (ด้านล่าง) |
@ -171,7 +171,7 @@ judes-note/
│ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe)
├── utils/
│ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก)
│ └── user.ts ← anonymous user identity (localStorage)
│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court)
├── hooks/
│ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters
├── app/
@ -248,7 +248,7 @@ judes-note/
## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope)
- ระบบ login/roles (ใช้ anonymous identity)
- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง)
- แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation`
- Undo/redo, export PDF ที่ฝัง annotation
- แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)

View file

@ -1,9 +1,9 @@
# รายงานเทียบ Requirement กับโปรเจกต์ (Gap Analysis)
- **วันที่ตรวจสอบ:** 2026-09-14
- **อัปเดตล่าสุด:** 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่
- **อัปเดตล่าสุด:** 2026-09-17 — ปิด gap 4 ข้อ: 5.3.11 ✅ (N5), 5.3.12 ✅ (N6), 5.3.14 ✅ (API ส่งออก bookmark เป็น JSON), 5.3.15 ✅ (export PDF ของ F8 มี bookmark ติดไป) · 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่
- **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System)
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` — ข้อ 5.3.11–5.3.15 อัปเดตตามโค้ดล่าสุด 2026-09-17 (รวม N5–N6 commit `a020e96` และ N7 → JSON export) · ตรวจครั้งแรก ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
- **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25**
> ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด จนถึง**ฉบับแก้ไข 2026-09-15** ที่เจ้าของเอกสารให้ข้อความข้อ 5.3.18–5.3.25 ฉบับถูกต้อง: HTML5 แยกจากข้อ Tablet/iPad ออกเป็นข้อ 5.3.19 ของตัวเอง (ข้อถัดมาเลื่อนลง 1 ลำดับ), เพิ่มข้อสิทธิการใช้งานตามระบบปกครอง (5.3.23) และเชื่อมโยงกับระบบปกครอง (5.3.25) — ข้อเดิมเรื่อง API ระบบอื่น, มาตรฐานความมั่นคงปลอดภัย และ Availability 99.5% ไม่ปรากฏในฉบับแก้ไข
@ -12,7 +12,7 @@
**โปรเจกต์ judes-note ตอบแกน functional ของ requirement ได้ค่อนข้างเต็ม** — การ annotate บนเอกสาร PDF, bookmark ต่อหน้าเอกสาร, Text Box + Freehand Drawing, ลบ/แก้ไขความเห็น, แสดงผลรายการ, ใช้งานหลายคนพร้อมกันแบบ realtime, responsive และรองรับ tablet/iPad
**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, API, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ค้นหาไม่ครอบ bookmark, ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ)
**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ)
| สถานะ | ความหมาย |
|---|---|
@ -28,7 +28,7 @@
| Requirement | สถานะ | รายละเอียดในโปรเจกต์ |
|---|---|---|
| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) และไม่มีหน้า upload — หน้า `/` อ่านรายการจาก DB เท่านั้น (sample data seed ด้วย SQL) — ผูกกับ gap 5.3.14 (API) และ 5.3.16 (integrate) |
| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) — อัปโหลดไฟล์เองได้แล้ว (N4) แต่ไม่ใช่การดึงจากระบบคดีปกครอง — ผูกกับ gap 5.3.16 (integrate) และ 5.3.25 (เชื่อมโยงระบบปกครอง) |
| **5.3.2** ระบบต้องไม่สามารถแก้ไขไฟล์ต้นฉบับได้ (Non-Destructive) | ✅ | เป็น **invariant หลักของโปรเจกต์** (CLAUDE.md ข้อ 1): annotation เก็บใน DB เป็นพิกัด % เท่านั้น, ไฟล์ PDF ใน `public/` ถูกอ่านอย่างเดียวผ่าน path guard (`lib/server-pdf.ts`), export PDF (F8) สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ (pdf-lib load แล้ว save คืน bytes ใหม่) — ไม่เขียนทับต้นฉบับเด็ดขาด |
| **5.3.3** จัดเก็บข้อมูล Bookmark แยกจากเอกสารโดยเก็บไว้ในฐานข้อมูล และสามารถนำข้อมูลดังกล่าวมาแสดงได้ | ✅ | เป็น**สถาปัตยกรรมแกนกลางของโปรเจกต์** (invariant #1–2): bookmark เก็บใน PostgreSQL ตาราง `bookmarks` เป็นพิกัด/ข้อมูลล้วน (pageIndex, ชื่อ, โครงสร้าง parent_id) แยกจากไฟล์ PDF ใน `public/` — นำกลับมาแสดงได้ทั้งแท็บ Bookmarks ใน sidebar (scroll ไปหน้านั้น), หน้าสรุป F15 และ export |
| **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ✅ | รองรับครบทุกนามสกุลแล้ว (N3 — แผน `docs/PLAN-IMAGE.md`, 2026-09-15): รูปคือ "หน้าเอกสาร" แสดง `<img>` แทน canvas + annotate ครบทุกชนิด + bookmark + realtime + export PDF ฝังรูป/CSV; TIFF รองรับทั้งหลายเฟรม (decode ด้วย utif2) และ EXIF orientation ตอน export; อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการได้ (N4) |
@ -45,21 +45,21 @@
| **5.3.8** ข้อมูลบันทึก bookmark ต้องอ้างถึงไฟล์เอกสาร หน้าเอกสาร วันที่บันทึก และผู้บันทึก | ✅ | ตาราง `bookmarks` มีครบทั้ง 4: `file_id` (FK → ไฟล์เอกสาร), `page_index` (หน้าเอกสาร), `created_at` (วันที่บันทึก), `user_id` (ผู้บันทึก) — นำมาแสดงในแท็บ Bookmarks, หน้าสรุป F15 และ export |
| **5.3.9** กำหนดความเป็นส่วนตัวของ bookmark: ส่วนบุคคล (Private) / กลุ่ม (Group) | ❌ | ไม่มี Private/Group visibility — ทุกคนเห็นทุกรายการ (ไม่มี auth/roles โดยเจตนา) |
| **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ⚠️ | รายไฟล์ ✅ — แท็บ Bookmarks ใน sidebar แสดง bookmark ทั้งหมดของไฟล์ที่เปิด (ผูก `file_id`) + หน้าสรุป F15 / แต่**ชุดของเอกสาร** ❌ — ไม่มี concept การจัดกลุ่มไฟล์ (document set) และไม่มีหน้าแสดง bookmark รวมข้ามหลายไฟล์ |
| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ⚠️ | แท็บ Bookmarks แสดงเลขหน้าต่อรายการ ("หน้า N") แต่เรียงตาม**เวลาสร้าง** ไม่ใช่ลำดับหน้า (`AnnotationSidebar.tsx:753` — root เรียงตามเวลาสร้าง) และหน้าสรุป F15 ไม่มี bookmark — แก้ง่าย: sort roots ของต้นไม้ตาม `pageIndex` |
| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ❌ | ช่องค้นหา (F14) ครอบเฉพาะ annotations เท่านั้น (`searchAnnotations` query ตาราง `annotations`) — **ค้นหา bookmark ยังไม่ได้เลย** ยิ่งไม่มีการกำหนดเงื่อนไข (เช่น ตามหน้า/ผู้บันทึก/ช่วงวันที่) |
| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ✅ | ทำแล้ว (2026-09-16, N5): แท็บ "ที่คั่นหน้า" เรียงตาม **หน้า → ตำแหน่งบนหน้า (yOffset) → เวลาสร้าง** ทั้ง root ของต้นไม้และลูกในแม่เดียวกัน — ลำดับเดียวกันใช้ทุกที่: PDF outline ตอน export (F8), ผลค้นหา (N6), ส่งออก JSON (N7) |
| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ✅ | ทำแล้ว (2026-09-16, N6): block ค้นหาเฉพาะแท็บ "ที่คั่นหน้า" — คำค้น (ชื่อ+โน้ต) + เงื่อนไข **ช่วงหน้า / ผู้บันทึก / ช่วงวันที่บันทึก** ผสมกันได้ + ปุ่มล้างเงื่อนไข (Server Action `searchBookmarks` — แผน `docs/PLAN-BOOKMARK.md`) |
| **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ❌ | ไม่มี REST API ภายนอก — มีเฉพาะ SSE stream ภายใน + export routes สำหรับผู้ใช้ (F11 export/import JSON ถูกถอดออกจากโค้ดแล้ว) |
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ⚠️ | ใกล้เคียง: export PDF ฝัง annotation + bookmark เป็น **PDF outline** (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุปรายการ (F16) — แต่ไม่ใช่ "รายงานรายการ bookmark" แบบเอกสาร PDF ตรง ๆ |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ทำแล้ว (2026-09-17): GET `/api/bookmarks/export?fileId=` คืน **JSON** ข้อมูล bookmark ทั้งไฟล์ (envelope `fileId`/`title`/`exportedAt` + `BookmarkDTO[]` ครบทุก field รวม `parentId` โครงสร้างต้นไม้ เรียงตามลำดับหน้า) — ระบบอื่นดึงข้อมูลไปใช้ต่อได้ทันที (read-only, ยังไม่มี auth ตามขอบเขตเฟสแรก) |
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ✅ | export PDF ของ F8 (`app/api/annotations/export/route.ts`) ฝัง bookmark เป็น **PDF outline** ติดไปกับไฟล์ (ต้นไม้ 3 ระดับ เรียงตามหน้า) — เจ้าของเอกสารยืนยัน (2026-09-17) ถือว่าตอบข้อนี้แล้ว · หมายเหตุ: รายงาน PDF แยกต่างหากของ N7 เคยทำแล้วถูกเปลี่ยนเป็น**ส่งออก JSON** ตามคำขอผู้ใช้ (2026-09-17) |
| **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed |
| **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว |
| **5.3.18** ใช้งานบน Tablet, iPad | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที (`hooks/useCustomSelection.ts` — แทนปุ่มลอยของ commit `b3b884c` ที่ถอดออกแล้ว), ลาก handles, วาดด้วยนิ้ว |
| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), Web Storage (anonymous identity), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet |
| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), HTTP Cookie (mock login e-Court), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet |
| **5.3.20** Responsive รองรับแสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) |
| **5.3.21** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน |
| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) |
| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — ไม่มี login จึงกำหนดสิทธิ์ตามระบบปกครองไม่ได้ (ต้องทำ 5.3.22 ก่อน) |
| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ✅ | ทำแล้วเป็น **mock** (2026-09-17): หน้า `/login` dropdown ผู้ใช้ `user1`–`user5` + รหัสผ่านกลาง `Demo1234` → ตั้ง cookie `oacCase` ตาม format จริงของ e-Court (userId = `u_userid` ต่างกันต่อคน, userName = `u_userlogin`) + `middleware.ts` บังคับทุกหน้า + ปุ่มออกจากระบบ — auth จริงเทียบระบบศาลยังไม่ทำ |
| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — login เป็น mock ยังไม่ผูกสิทธิ์ตามระบบปกครอง (ต้อง auth จริง 5.3.22 ก่อน) |
| **5.3.24** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) |
| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration/API เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.14 ชุด API) |
| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.16 โหมด integrate) — ด้านข้อมูลออกมี JSON export รอรับแล้ว (5.3.14) |
---
@ -70,16 +70,16 @@
- Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23)
- Private/Group visibility ของ bookmark (5.3.9)
- ชุด API สำหรับระบบภายนอก (5.3.14)
- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว (2026-09-17)**: GET `/api/bookmarks/export` คืน JSON ข้อมูล bookmark ทั้งไฟล์ ระบบอื่นดึงไปใช้ได้ (ดูตาราง 5.3.14)
- โหมด Integrate แบบ modal/iframe (5.3.16)
- เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25)
### กลุ่ม B — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย
งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม:
งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม — **ปิดครบทั้งกลุ่มแล้ว (2026-09-16–17)** เก็บรายการไว้เป็นประวัติ:
- **ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12) — เพิ่มการค้นหาตาราง `bookmarks` (ชื่อ/โน้ต) + รองรับเงื่อนไขเช่นหน้าเอกสาร/ผู้บันทึก/ช่วงวันที่
- **เรียง bookmark ตามลำดับหน้า** (5.3.11) — sort roots ของต้นไม้ bookmark ตาม `pageIndex` แทนเวลาสร้าง
- **รายงานรายการ bookmark เป็น PDF** (5.3.15) — ต่อจากหน้าสรุป F15 หรือ export route เดิม
- ~~**ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12)~~ — **ทำแล้ว (2026-09-16, N6)**: คำค้น (ชื่อ/โน้ต) + ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ ในแท็บ "ที่คั่นหน้า"
- ~~**เรียง bookmark ตามลำดับหน้า** (5.3.11)~~ — **ทำแล้ว (2026-09-16, N5)**: เรียง หน้า → ตำแหน่งบนหน้า → เวลาสร้าง ทั้งต้นไม้ + ใช้ลำดับเดียวกันใน export PDF outline
- ~~**รายงานรายการ bookmark เป็น PDF** (5.3.15)~~ — **ปิดแล้ว (2026-09-17)**: เจ้าของเอกสารถือว่า export PDF ของ F8 ที่ฝัง bookmark เป็น outline ตอบข้อนี้แล้ว; งาน N7 ที่ทำไว้ (รายงาน PDF แยก) เปลี่ยนเป็นส่งออก JSON แทน (ซึ่งไปปิด 5.3.14 พอดี)
### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน
- ~~**รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4)~~ — **ทำแล้ว (2026-09-15, N3 + N4 ตามแผน `docs/PLAN-IMAGE.md`)**: รูปคือ "หน้าเอกสาร" (layers เดิมซ้อนบน `<img>` พิกัด % ใช้ของเดิมได้ทันที) + TIFF หลายเฟรม + EXIF orientation + export PDF ฝังรูป + อัปโหลดผ่านหน้ารายการ
@ -92,6 +92,6 @@
## 4. ข้อเสนอแนะลำดับความสำคัญ
1. **ทำกลุ่ม B ก่อน** — คุ้มค่าต่อแรง เห็นผลไว เสริมฟีเจอร์ bookmark ให้ครบ requirement โดยไม่แตะสถาปัตยกรรม
1. ~~**ทำกลุ่ม B ก่อน**~~ — **ทำเสร็จแล้ว (2026-09-16–17)**: N5+N6 ปิด 5.3.11/5.3.12, 5.3.15 ปิดด้วย export PDF ของ F8 (bookmark เป็น outline), 5.3.14 ปิดด้วย JSON export — สิ่งที่เหลืออยู่ในกลุ่ม A/C/D
2. ~~**ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)**~~ — **ตัดสินใจและทำเสร็จแล้ว (2026-09-15)**: 5.3.4 ครบทุกนามสกุลตาม N3 + อัปโหลดไฟล์ (N4)
3. **กลุ่ม A ตามด้วย D** — ต้องอัปเดต `docs/PLAN.md` ขยายขอบเขตอย่างเป็นทางการก่อน implement ตามกระบวนการของโปรเจกต์

8
lib/session.ts Normal file
View file

@ -0,0 +1,8 @@
// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page
import { cookies } from "next/headers";
import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user";
export function getSessionUser(): OacUser | null {
return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value);
}

24
middleware.ts Normal file
View file

@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user";
// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login
// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ)
export function middleware(req: NextRequest) {
const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value);
if (!user) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.search = "";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
export const config = {
matcher: [
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
],
};

58
tests/utils/user.test.ts Normal file
View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
buildOacCaseCookie,
OAC_CASE_COOKIE,
parseOacCase,
} from "@/utils/user";
/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */
const REAL_OAC_CASE =
"u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName=";
describe("parseOacCase", () => {
it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => {
expect(parseOacCase(REAL_OAC_CASE)).toEqual({
userId: "235821",
userName: "tu_2",
});
});
it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => {
expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({
userId: "user1",
userName: "user1",
});
});
it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => {
expect(parseOacCase(null)).toBeNull();
expect(parseOacCase(undefined)).toBeNull();
expect(parseOacCase("")).toBeNull();
expect(parseOacCase("garbage")).toBeNull();
expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid
expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง
});
it("ค่ารอบด้วย space ถูก trim", () => {
expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({
userId: "123",
userName: "tu_2",
});
});
it("ชื่อ cookie ตรงกับระบบ e-Court", () => {
expect(OAC_CASE_COOKIE).toBe("oacCase");
});
});
describe("buildOacCaseCookie", () => {
it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => {
const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x")));
expect(fields["u_userid"]).toBe("xxxxxx");
expect(fields["u_userlogin"]).toBe("user_x");
expect(fields["u_courtid"]).toBe("4");
expect(fields["u_perCardNo"]).toBe("5000000000006");
expect(fields["u_AuditID"]).toBe("1995227");
expect(Object.keys(fields)).toHaveLength(16);
});
});

View file

@ -1,51 +1,53 @@
// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4)
// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth
// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase"
// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม
// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers
export type LocalUser = { userId: string; userName: string };
export type OacUser = { userId: string; userName: string };
const STORAGE_KEY = "judes-note:user";
export const OAC_CASE_COOKIE = "oacCase";
// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่
// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh
export const MOCK_USERS: LocalUser[] = [
{ userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" },
{ userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" },
{ userId: "mock-user-03", userName: "อัยการ วิชัย" },
{ userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" },
{ userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" },
{ userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" },
{ userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" },
{ userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" },
{ userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" },
{ userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" },
/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server)
* ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */
export const MOCK_USERS: readonly string[] = [
"user1",
"user2",
"user3",
"user4",
"user5",
];
export function setLocalUser(user: LocalUser): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
}
// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้
export function getStoredLocalUser(): LocalUser | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as LocalUser;
if (typeof parsed.userId === "string" && typeof parsed.userName === "string") {
return parsed;
}
} catch {
// ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล
}
}
return null;
}
export function getLocalUser(): LocalUser {
const stored = getStoredLocalUser();
if (stored) return stored;
const userId = crypto.randomUUID();
const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName }));
/** แยกค่า query string ของ cookie oacCase → identity
* ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null)
* หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว
* จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */
export function parseOacCase(raw: string | null | undefined): OacUser | null {
if (!raw) return null;
const params = new URLSearchParams(raw);
const userId = params.get("u_userid")?.trim();
const userName = params.get("u_userlogin")?.trim();
if (!userId || !userName) return null;
return { userId, userName };
}
/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง)
* mock login แทนเฉพาะ u_userid/u_userlogin */
export function buildOacCaseCookie(userId: string, userLogin: string): string {
return [
`u_userid=${userId}`,
`u_userlogin=${userLogin}`,
"u_courtid=4",
"u_password=",
"u_AdminStatus=",
"u_perCardNo=5000000000006",
"u_perCardNoEncript=1800650010005002003",
"u_userType=4",
"u_CidAuthen=NNNENNNNNNNNNNNNNNNN",
"u_hSecId=NNNNNNNNNNNNNNNNNNNN",
"u_username=",
"u_courtName=",
"u_AuditID=1995227",
"u_PsGroupNum=1",
"u_PsGroup=11",
"u_PsGroupName=",
].join("&");
}