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>
This commit is contained in:
Your Name 2026-09-17 13:41:02 +07:00
parent 460cd9c600
commit 65b72cf817
16 changed files with 414 additions and 131 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) - 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`) - 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 - 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 ที่ต้องรักษา (ห้ามละเมิด) ## Invariants ที่ต้องรักษา (ห้ามละเมิด)
@ -34,26 +34,30 @@
``` ```
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) 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/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้) 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/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) 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/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) 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) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route)
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event 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/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4)
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) 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/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) 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/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
app/page.tsx ← รายการไฟล์ (server component) app/page.tsx ← รายการไฟล์ (server component)
app/login/page.tsx ← หน้า login (มี session แล้ว redirect /)
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) 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/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4)
components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/"))
components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม)
``` ```
## ขอบเขต — ห้ามทำเกิน (Out of Scope) ## ขอบเขต — ห้ามทำเกิน (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) - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น - ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
- หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`) - หน้า `/` อ่านรายการจาก 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 กลับตำแหน่งเดิม - **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม
- **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด) - **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด)
- **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น - **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น
- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง) - หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง)
## สิ่งที่ต้องมี (Prerequisites) ## สิ่งที่ต้องมี (Prerequisites)
@ -85,7 +85,7 @@ npm run dev
| bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน | | bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน |
| zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) | | zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) |
Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`) Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login)
ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh ทดสอบ 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) db/index.ts postgres.js + drizzle (prepare: false)
lib/annotate-events.ts pub/sub in-process (actions → SSE route) lib/annotate-events.ts pub/sub in-process (actions → SSE route)
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine 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 hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=) app/api/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 { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { db } from "@/db"; import { db } from "@/db";
import { import {
@ -13,6 +13,7 @@ import {
} from "@/db/schema"; } from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer"; import PDFViewer from "@/components/pdf/PDFViewer";
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
import { getSessionUser } from "@/lib/session";
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[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; const { id } = params;
if (!UUID_RE.test(id)) notFound(); 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)); const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound(); if (!file) notFound();
@ -62,6 +67,7 @@ export default async function AnnotatePage({
initialAnnotations={annotationRows.map(toAnnotationDTO)} initialAnnotations={annotationRows.map(toAnnotationDTO)}
initialBookmarks={bookmarkRows.map(toBookmarkDTO)} initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
initialReplies={replyRows.map(toReplyDTO)} initialReplies={replyRows.map(toReplyDTO)}
user={user}
/> />
</> </>
); );

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 Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { FileImage, FileText } from "lucide-react"; import { FileImage, FileText } from "lucide-react";
import { db } from "@/db"; import { db } from "@/db";
import { files } from "@/db/schema"; import { files } from "@/db/schema";
import UserPicker from "@/components/UserPicker"; import SessionBar from "@/components/SessionBar";
import UploadFileForm from "@/components/UploadFileForm"; import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind"; import { imageFormatFromUrl } from "@/utils/file-kind";
import { getSessionUser } from "@/lib/session";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -25,6 +27,10 @@ function kindBadge(pdfUrl: string): string {
} }
export default async function HomePage() { 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)); const rows = await db.select().from(files).orderBy(asc(files.createdAt));
return ( return (
@ -43,7 +49,7 @@ export default async function HomePage() {
</div> </div>
</header> </header>
<UserPicker /> <SessionBar userName={user.userName} />
<section className="mt-8"> <section className="mt-8">
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2> <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 Rect,
type Size, type Size,
} from "@/utils/coordinates"; } from "@/utils/coordinates";
import { getLocalUser, type LocalUser } from "@/utils/user"; import type { OacUser } from "@/utils/user";
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
import { decodeTiffPages } from "@/utils/tiff-client"; import { decodeTiffPages } from "@/utils/tiff-client";
import { import {
@ -1138,11 +1138,14 @@ export default function PDFViewer({
initialAnnotations, initialAnnotations,
initialBookmarks, initialBookmarks,
initialReplies, initialReplies,
user,
}: { }: {
file: { id: string; title: string; pdfUrl: string }; file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[]; initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[]; initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[]; initialReplies: ReplyDTO[];
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
user: OacUser;
}) { }) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null); const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | 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 [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null); const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [user, setUser] = useState<LocalUser | null>(null);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
@ -1323,11 +1325,10 @@ export default function PDFViewer({
[replies, visibleAnnotations], [replies, visibleAnnotations],
); );
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
useEffect(() => {
setUser(getLocalUser());
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
// (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect)
useEffect(() => {
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false); if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
}, []); }, []);

View file

@ -11,7 +11,7 @@
| A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว | | A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว |
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict | | 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 | | 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` (รายละเอียดด้านล่าง) | | 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 ในเฟสนี้ | | 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 | | 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 | | | id | uuid PK | |
| file_id | uuid FK → files ON DELETE CASCADE | | | file_id | uuid FK → files ON DELETE CASCADE | |
| page_index | integer NOT NULL | 0-based | | 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 | | | user_name | text NOT NULL | |
| type | annotation_type NOT NULL | | | type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | **strict typed union** (ด้านล่าง) | | 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) │ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe)
├── utils/ ├── utils/
│ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก) │ ├── 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/ ├── hooks/
│ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters │ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters
├── app/ ├── app/
@ -248,7 +248,7 @@ judes-note/
## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope) ## 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` - แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation`
- Undo/redo, export PDF ที่ฝัง annotation - Undo/redo, export PDF ที่ฝัง annotation
- แสดงรายชื่อ "ใครออนไลน์อยู่" (presence) - แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)

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) // identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase"
// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth // (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 คงที่ /** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server)
// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh * ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */
export const MOCK_USERS: LocalUser[] = [ export const MOCK_USERS: readonly string[] = [
{ userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" }, "user1",
{ userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" }, "user2",
{ userId: "mock-user-03", userName: "อัยการ วิชัย" }, "user3",
{ userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" }, "user4",
{ userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" }, "user5",
{ 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: "ผู้ช่วยเลขานุการ ฝ้าย" },
]; ];
export function setLocalUser(user: LocalUser): void { /** แยกค่า query string ของ cookie oacCase → identity
localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); * ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null)
} * หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว
* จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */
// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้ export function parseOacCase(raw: string | null | undefined): OacUser | null {
export function getStoredLocalUser(): LocalUser | null { if (!raw) return null;
const raw = localStorage.getItem(STORAGE_KEY); const params = new URLSearchParams(raw);
if (raw) { const userId = params.get("u_userid")?.trim();
try { const userName = params.get("u_userlogin")?.trim();
const parsed = JSON.parse(raw) as LocalUser; if (!userId || !userName) return null;
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 }));
return { userId, userName }; 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("&");
}