diff --git a/CLAUDE.md b/CLAUDE.md index f2eeafd..56919df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/README.md b/README.md index ba8a69b..ae13d78 100644 --- a/README.md +++ b/README.md @@ -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=) diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..79499a9 --- /dev/null +++ b/app/actions/auth.ts @@ -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 { + // ลบ 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"); +} diff --git a/app/annotate/[id]/page.tsx b/app/annotate/[id]/page.tsx index 6d1e4a3..50c1b4c 100644 --- a/app/annotate/[id]/page.tsx +++ b/app/annotate/[id]/page.tsx @@ -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} /> ); diff --git a/app/api/bookmarks/export/route.ts b/app/api/bookmarks/export/route.ts index c3b6aab..675d470 100644 --- a/app/api/bookmarks/export/route.ts +++ b/app/api/bookmarks/export/route.ts @@ -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(); - 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(); - 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}`, }, }); } diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..51bf203 --- /dev/null +++ b/app/login/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 64f2e83..7ed5772 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { - +

เพิ่มเอกสาร

diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..08e4365 --- /dev/null +++ b/components/LoginForm.tsx @@ -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(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const onSubmit = (e: React.FormEvent) => { + 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 ( +
+ {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} +
+
+ JN +
+
+

Judge's Note

+

ระบบ PDF Annotation สำหรับ e-Court

+
+
+ +

เข้าสู่ระบบ

+ +
+
+ +
+ +
+
+ +
+ + +
+ + + + {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */} +

+ รหัสผ่านทดสอบ:{" "} + Demo1234 +

+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..c95704d --- /dev/null +++ b/components/LogoutButton.tsx @@ -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 ( + + ); +} diff --git a/components/SessionBar.tsx b/components/SessionBar.tsx new file mode 100644 index 0000000..360d950 --- /dev/null +++ b/components/SessionBar.tsx @@ -0,0 +1,15 @@ +// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component +// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้) +import LogoutButton from "./LogoutButton"; + +export default function SessionBar({ userName }: { userName: string }) { + return ( +
+

+ เข้าสู่ระบบเป็น{" "} + {userName} +

+ +
+ ); +} diff --git a/components/UserPicker.tsx b/components/UserPicker.tsx deleted file mode 100644 index 1717fa7..0000000 --- a/components/UserPicker.tsx +++ /dev/null @@ -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(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 ( -
-

เลือกผู้ใช้ (mock)

-

- annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก -

- -
- ); -} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index aacf4ae..e154660 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -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(null); const [doc, setDoc] = useState(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(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); }, []); diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index d8067a2..6e9e791 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -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({ )} - {/* N7: ส่งออกรายการ bookmark เป็น PDF — ลิงก์ธรรมดา pattern Export CSV (F16) + {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} - + {bmShowConditions && ( diff --git a/docs/PLAN-BOOKMARK-PDF.md b/docs/PLAN-BOOKMARK-PDF.md index 66c7e26..d5ac927 100644 --- a/docs/PLAN-BOOKMARK-PDF.md +++ b/docs/PLAN-BOOKMARK-PDF.md @@ -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` diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md new file mode 100644 index 0000000..63ece3c --- /dev/null +++ b/docs/PLAN-DOCSET.md @@ -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 ยังทำงานเหมือนเดิม diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index dbf1461..0877b8a 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -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 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (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 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (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) diff --git a/docs/PLAN.md b/docs/PLAN.md index f2f6c2e..fd65c3f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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/.` เท่านั้น (ไม่แตะไฟล์เดิม) — 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()` | **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) diff --git a/docs/requirement/gap-analysis.md b/docs/requirement/gap-analysis.md index 7b2b8b7..716f74c 100644 --- a/docs/requirement/gap-analysis.md +++ b/docs/requirement/gap-analysis.md @@ -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): รูปคือ "หน้าเอกสาร" แสดง `` แทน 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 เดิมซ้อนบน `` พิกัด % ใช้ของเดิมได้ทันที) + 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 ตามกระบวนการของโปรเจกต์ diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..90c2029 --- /dev/null +++ b/lib/session.ts @@ -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); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..bf8b582 --- /dev/null +++ b/middleware.ts @@ -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).*)", + ], +}; diff --git a/tests/utils/user.test.ts b/tests/utils/user.test.ts new file mode 100644 index 0000000..3da2828 --- /dev/null +++ b/tests/utils/user.test.ts @@ -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); + }); +}); diff --git a/utils/user.ts b/utils/user.ts index 51dca51..a173b20 100644 --- a/utils/user.ts +++ b/utils/user.ts @@ -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("&"); +}