# Judge's Note — PDF Annotation สำหรับ e-Court ระบบ annotate ไฟล์ PDF คดีบนเว็บ แบบ **Non-Destructive** — ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไขเด็ดขาด annotation ทั้งหมด (highlight / เส้นวาด / comment / bookmark) เก็บใน PostgreSQL เป็นพิกัด % ของขนาดหน้า แล้วซิงก์ระหว่างทุก browser แบบ realtime ผ่าน **SSE (Server-Sent Events)** สร้างด้วย Next.js 14 (App Router) + TypeScript strict + pdfjs-dist v4 + Drizzle ORM + PostgreSQL (Docker container เดียว) ## ฟีเจอร์ - **Highlight** — คลุมดำข้อความ เลือกสีจาก floating toolbar (5 สี) วาดซ้อนหลังข้อความด้วย `mix-blend-mode: multiply` - **Freehand draw** — วาดเส้นอิสระบน SVG layer ความหนาสัมพัทธ์กับขนาดหน้า ไม่เบี้ยวเวลา zoom - **Sticky note comment** — คลิกวาง pin บนหน้า พร้อม popover ข้อความ/ผู้เขียน/เวลา - **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม - **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด) - **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น - หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง) ## สิ่งที่ต้องมี (Prerequisites) | เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ | |---|---|---| | Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ | | Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว | ## ขั้นตอน Setup จากศูนย์ ### 1. ติดตั้ง dependencies ```bash npm install ``` > postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ ### 2. รัน PostgreSQL container ```bash docker run -d --name judes-note-db \ -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \ -p 54322:5432 \ -v judes-note-db-data:/var/lib/postgresql/data \ postgres:17-alpine ``` ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย ### 3. สร้างไฟล์ `.env` ```env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server # ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # EXTERNAL_API_KEY= ``` (เทียบกับ `.env.example`) ### 4. สร้างตารางในฐานข้อมูล ```bash npx drizzle-kit push ``` ### 5. Seed ไฟล์ตัวอย่าง ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3): ```bash npm run seed-samples ``` > บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE) ### 6. รัน dev server ```bash npm run dev ``` เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`) ## การใช้งานบนหน้า annotate | ปุ่ม | พฤติกรรม | |---|---| | ลูกศร (select) | คลุมดำข้อความ → เด้ง toolbar เลือกสี → ติด highlight บนหน้า | | ดินสอ (draw) | ลากเมาส์วาดเส้นอิสระ (ปล่อย = บันทึก) | | ลูกศร+กล่องแชท (comment) | คลิกจุดบนหน้า → พิมพ์ข้อความ → บันทึก pin | | bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน | | zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) | Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login) ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh ## API สำหรับระบบภายนอก (N10) Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — รายละเอียดเต็ม: `docs/PLAN-API.md` ```jsonc // POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath) // headers: x-api-key + content-type: application/json { "fileId": "", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND) "typeId": "", // ถามทุกเอกสารในกลุ่ม N9 — กลุ่มว่าง = 200 documents: [] "filter": { "type": "bookmarks", // optional: "bookmarks" | "annotations" — ไม่ส่ง (หรือ type เป็น "") = ทั้งสอง "userId": "", // optional: string หรือ array — กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks) "annotationTypes": ["COMMENT"], // optional: ค่าจาก HIGHLIGHT|DRAW|COMMENT|SHAPE|REDACTION|TEXTBOX|STAMP — เฉพาะ annotations } } ``` - Response 200 เป็นรูปเดียวเสมอ: `{ "documents": [ { "id", "externalFileId", "title", "typeId", "createdAt", "annotations": [… พร้อม url + replies], "bookmarks": [… พร้อม url] } ] }` (field เอกสารอยู่ระดับเดียวกับ annotations/bookmarks) — ถามด้วย fileId ได้ array ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) - `url` ต่อรายการ = deep link กลับมาหน้า viewer เป็น **URL เต็มพร้อมใช้ทันที** (`http:///docedit/note/annotate/?ann=` สำหรับ annotation · `?page=<หน้า 1-based>` สำหรับ bookmark) — origin อ่านจาก request ที่เรียกเข้ามา (ผ่าน reverse proxy ต้องตั้งส่งต่อ `x-forwarded-proto`/`x-forwarded-host`) - error ทุกสถานะ (400/401/404/500) เป็น JSON `{ "error": "<ข้อความไทย>" }` รูปเดียวกันทั้ง endpoint — 500 เกิดเมื่อ query ฐานข้อมูลล้ม (log รายละเอียดฝั่ง server อย่างเดียว) - ตัวอย่าง: ```bash curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \ -d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \ "http://localhost:3000/docedit/note/api/external/documents" curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \ -d '{"typeId":"CASE-2569-0001"}' \ "http://localhost:3000/docedit/note/api/external/documents" ``` ## Troubleshooting | อาการ | สาเหตุ/ทางแก้ | |---|---| | Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) | | หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build | | `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ | | ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น | | Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) | ## หมายเหตุทางเทคนิค - **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ) - UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ - พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight - สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md` ## โครงสร้างหลัก ``` 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 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=) app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4) app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar components/sidebar/ AnnotationSidebar public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4) ```