eCourt-note/docs/PLAN-FEAT.md
Your Name 651e354cad stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:56:00 +07:00

92 KiB
Raw Permalink Blame History

📋 Implementation Plan — Features ต่อยอด (Judge's Note)

แผนงาน implement ฟีเจอร์ต่อยอดจากเฟสแรกที่ implement ครบแล้วตาม docs/PLAN.md — ยึดสถาปัตยกรรมเดิมทั้งหมด: พิกัด %, JSONB discriminated union, เขียนผ่าน Server Actions + publish event, SSE sync, Non-Destructive

ใช้งาน: /implement-feat <F-number หรือ feature id เช่น A1> (skill .claude/skills/implement-feat) — อ้างตาราง §4 ของไฟล์นี้


0. ข้อตกลง (Decisions)

# หัวข้อ ข้อตกลง
D1 Realtime ต่อยอด SSE เดิม — เพิ่ม event variant (§2) ไม่เพิ่มระบบใหม่
D2 A4 Reply ตารางใหม่ annotation_replies (FK → annotations CASCADE) ไม่ใช้ parentId ในตารางเดิม — union/ตารางเดิมไม่ต้องแตะ และ query เธรดได้ตรง ๆ
D3 B1 line/arrow เก็บ from/to (จุด 2 จุด, % ของหน้า) แทน rect เฉย ๆ — ทิศทางลูกศร/ความเฉียงของเส้นต้องไม่หาย; ส่วน rect/ellipse ใช้ rect
D4 B4 + C1 ทำรวมเป็น task เดียว (F8) เสมอ — redaction ที่ไม่ติดไปกับไฟล์ export คือช่องโหว่การรั่วของข้อมูล
D5 C1 ฟอนต์ไทย pdf-lib วาดข้อความไทยต้อง embed ฟอนต์: Sarabun-Regular.ttf (SIL OFL) วางที่ public/fonts/ — ถ้าไฟล์ไม่มี → COMMENT/STAMP/TEXTBOX ใน export วาดเป็นกรอบ/หมุดอย่างเดียว ต้องไม่พัง
D6 A5 Resolve ใครก็ได้ toggle (คลายคืนได้ ไม่ทำลายข้อมูล — ต่างจาก delete ที่จำกัดเจ้าของเหมือนเดิม)
D7 B5 Signature stamp รับคำพิมพ์เองได้แล้ว (2026-09-28: preset + popup "กำหนดเอง" พร้อม preview + แก้คำหลังวาง); ลายเซ็น = ใช้ tool DRAW วาด (upload รูปต้องมีที่เก็บไฟล์ → out of scope)
D8 A7 Versions annotation_versions.annotation_id เป็น uuid ธรรมดา ไม่ FK — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่; restore = สร้าง annotation ใหม่ id ใหม่
D9 A1 ย้าย/resize ในหน้าเดียวกันเท่านั้น (pageIndex ไม่เปลี่ยน — การข้ามหน้าเก็บ pageIndex เดี่ยวไม่ได้ตาม schema เฟสแรก); แก้ได้เฉพาะของตัวเอง (guard userId เหมือน delete)
D10 A2 Undo/Redo history ฝั่ง client ต่อ session ต่อผู้ใช้ — ไม่เก็บใน DB (A7 แยกต่างหาก)
D11 C2 Import คง userId/userName ต้นฉบับ ไว้ (เห็นที่มาว่าใครเขียน) — ผู้ import ลบรายการที่ไม่ใช่ของตัวเองไม่ได้ตามกติกาเดิม; id สุ่มใหม่ทั้งหมดกันชน PK

1. Schema & Type Changes (รวมทุกเฟสไว้ที่เดียว)

ทุก task ที่แตะ db/schema.ts ต้องรัน /sync-db หลังแก้ และเติม validation ใน validateAnnotationData ครบทุก case ใหม่ (invariant #8 — ห้าม any)

Enum annotation_type ขยายเป็น

HIGHLIGHT | DRAW | COMMENT | SHAPE | REDACTION | TEXTBOX | STAMP

JSONB union เพิ่ม member (เติมใน AnnotationData)

// Point ต้อง export จาก db/schema.ts (ตอนนี้ type Point อยู่ใน utils/coordinates.ts — ย้ายมา schema แล้ว re-export)
type ShapeData =
  | { shape: "rect" | "ellipse"; rect: Rect; strokeColor: string; strokeWidth: number }
  | { shape: "line" | "arrow"; from: Point; to: Point; strokeColor: string; strokeWidth: number };
type RedactionData = { rect: Rect };                                  // ดำทึบ — ไม่มีสีให้เปลี่ยน
type TextboxData  = { position: Point; text: string; color: string; fontSize: number }; // fontSize = % ของ width
type StampData    = { label: string; position: Point; scale: number };                  // scale = % ของ width
  • strokeWidth ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
  • E1 (F7) เพิ่ม field optional widths?: number[] ใน DrawData (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure)
  • F17 (B2) ภายหลังเพิ่ม bold?/opacity?/align?/segments?/width? ใน TextboxData — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ): กล่องกว้างคงที่เมื่อมี width (คลิกเดียวได้กล่อง default 30% ลากขอบซ้าย/ขวาปรับความกว้าง ข้อความ wrap ตาม ฟอนต์คงขนาด) ไม่มี width = shrink-to-fit หุ้มข้อความ (แถวเดิม) — ความกว้างสูงสุดคำนวณด้วย textboxMaxWidthPct (cap 50% ของหน้าและบีบตามพื้นที่จริงถึงขอบขวา — กันข้อความล้นกระดาษ) ใช้สูตรเดียวกันทั้ง render จอ / bbox กรอบ selection / export PDF

Column เพิ่มในตาราง annotations (F10)

column type หมายเหตุ
resolved boolean NOT NULL default false
resolved_at timestamptz NULL

ตารางใหม่

annotation_replies (F9)

column type
id uuid PK defaultRandom()
annotation_id uuid FK → annotations ON DELETE CASCADE
user_id text NOT NULL
user_name text NOT NULL
message text NOT NULL
created_at timestamptz defaultNow()

Index: (annotation_id)

annotation_versions (F19)

column type หมายเหตุ
id uuid PK defaultRandom()
annotation_id uuid NOT NULL ไม่ FK (D8)
file_id uuid FK → files ON DELETE CASCADE ลบไฟล์ → ประวัติหายไปด้วย
page_index integer NOT NULL หน้าของ annotation ตอน snapshot — restoreVersion ต้องรู้หน้าจึงสร้างใหม่ได้ (annotations.pageIndex เป็น NOT NULL)
user_id / user_name text NOT NULL เจ้าของ annotation ตอน snapshot
type annotation_type NOT NULL
data jsonb NOT NULL $type<AnnotationData>() snapshot ข้อมูลก่อนเปลี่ยน
kind text NOT NULL 'update' | 'delete'
created_at timestamptz defaultNow()

Index: (file_id)

DTO

  • AnnotationDTO เพิ่ม resolved: boolean และ resolvedAt: string | null
  • เพิ่ม ReplyDTO, AnnotationVersionDTO + mapper toDTO — timestamp เป็น ISO string ทั้งหมด (invariant #5)

2. Realtime Event Changes

AnnotateEvent (lib/annotate-events.ts) เพิ่ม variant:

| { table: "annotations"; action: "update"; row: AnnotationDTO }
| { table: "replies"; action: "create"; row: ReplyDTO }
| { table: "replies"; action: "delete"; id: string }
  • hooks/useAnnotationRealtime.ts: update → replace ตาม id (idempotent — มาก่อน/หลัง optimistic ก็ได้ผลเดิม); เพิ่ม state replies: ReplyDTO[]
  • useOptimistic ใน PDFViewer: AnnotationAction เพิ่ม { kind: "update"; row } — ใช้ภายใน startTransition เหมือนเดิม (invariant #9)
  • Server Actions ตัวใหม่ทุกตัวที่เขียน DB (update / resolve / reply / import / restore) ต้อง publish หลังเขียนสำเร็จทุกครั้ง (invariant #4)

3. สเปกฟีเจอร์ที่ต้องนิยามพฤติกรรมเพิ่ม

F1 · A1 แก้ไข/ย้าย/ปรับขนาด

  • Server Action updateAnnotation(id, userId, data) — โหลด row เดิม (เจ้าของเท่านั้น D9), ห้ามเปลี่ยน type/pageIndex, validate data ด้วย validateAnnotationData เดิม, update data + updatedAt, publish { action: "update", row }
  • ไฟล์ใหม่ utils/geometry.ts (pure functions, ทดสอบได้): annotationBBox(annotation, size) / translateAnnotationData(data, dxPct, dyPct) / scaleAnnotationData(data, anchorPct, sx, sy) / hitTestPath(points, point, thresholdPx, size) (ใช้ซ้ำ F6)
  • การเลือก: คลิกรายการใน sidebar (หรือคลิก comment pin) → state selectedId ใน PDFViewer → components/pdf/AnnotationSelectionLayer.tsx (ใหม่, z-4 เหมือน comment pins) วาดกรอบ bbox + handles:
    • container pointer-events: none; handle เท่านั้น ที่ pointer-events: auto → ไม่แตะ invariant #3 (highlight-layer ยัง none เสมอ, textLayer ยัง select ข้อความได้)
    • ลากตัวกรอบ = translate ทั้งกลุ่ม; ลากมุม = scale รอบ anchor มุมตรงข้าม (apply กับ rects ทุกอัน / points ทุกจุด / position)
    • pointerup → คำนวณข้อมูล % ใหม่ → optimistic update + updateAnnotation
  • เปลี่ยนสี/ความหนา: ปุ่มเล็กบนกรอบ selection → ใช้ updateAnnotation เดียวกัน

F2 · A2 Undo/Redo

  • hooks/useHistory.ts (ใหม่): stack ของ { undo(): Promise<void>; redo(): Promise<void> } — push ทุกครั้งที่ create/delete/update ของตัวเอง สำเร็จ; action ใหม่ → เคลียร์ redo stack
  • undo ของ create = delete(id); ของ delete = create ใหม่จาก data ที่เก็บไว้ (id ใหม่); ของ update = update กลับเป็น data ก่อนหน้า
  • UI: ปุ่ม Undo/Redo บน toolbar (disabled ตาม stack) + Ctrl+Z / Ctrl+Shift+Z (จับคู่ F3)
  • Edge: undo(create) แต่ row ถูกลบไปแล้วโดยแท็บอื่น → delete โดน reject → แสดง console + ข้าม (ไม่ crash)

F5 · B1 รูปทรง

  • Tool shape + sub-state ชนิดรูปทรง (rect | ellipse | line | arrow) — ปุ่มเลือกชนิดแสดงเมื่อ tool ใช้งาน
  • InkLayer (SVG z-3 เดิม) รับหน้าที่เพิ่ม: pointerdown → ลาก → preview → pointerup → createAnnotation("SHAPE", ...) (rect/ellipse เก็บ bbox เป็น rect; line/arrow เก็บจุดเริ่ม/จุดปลายเป็น from/to — D3)
  • Render รายการเดิม: <rect> / <ellipse cx cy rx ry> / <line> / arrow = <line> + หัวลูกศร <path> คำนวณจาก from→to
  • Sidebar: รายการ SHAPE แสดง label ตามชนิด ("สี่เหลี่ยม", "วงรี", "เส้นตรง", "ลูกศร")

F6 · B3 Eraser

  • Tool erase: InkLayer active (รับ pointer, touch-action: none), pointerdown/move ทำ hit-test ตำแหน่ง pointer กับเส้น/รูปทรง/ตรายาง ของตัวเองเท่านั้น (ส่ง userId มากับ strokes):
    • DRAW: ระยะจากจุดถึง segment ทุกท่อน ≤ strokeWidth/2 + 6px
    • SHAPE: ระยะถึงเส้นขอบ (stroke) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
    • STAMP: จุดอยู่ใน bbox ของตรา (annotationBBox สูตรเดียวกับ render) + 6px — เช็คก่อนเส้น/รูปทรงเพราะตรา render อยู่ layer บน (TextboxStampLayer) ให้ตัวบนสุดชนะ
  • โดน = deleteAnnotation ทันที (ลากผ่านหลายเส้น = ลบทีละเส้นระหว่างเคลื่อน) — เส้นของคนอื่นไม่ผ่าน hit-test จึงลบไม่ได้

F8 · B4+C1 Redaction + Export PDF (ทำคู่กันเสมอ — D4)

  • REDACTION บนจอ: วาดบน InkLayer เป็น <rect> fill #000 (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
  • Export: app/api/annotations/export/route.ts (GET ?fileId=) — dep ใหม่ pdf-lib + @pdf-lib/fontkit:
    1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก path.join(process.cwd(), "public", pdfUrl) — อ่านอย่างเดียว ห้ามเขียนทับ (invariant #1) + guard path ต้อง resolve อยู่ใน public/ กัน path traversal
    2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง PDFDocument ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (page.getSize() + แปลงตาม page.getRotation() กรณี /Rotate 90/180/270) — เพิ่มพารามิเตอร์ &user=<userId> (2026-09-22): export ตาม filter รายคน F4 ที่กำลังดู ("mine" resolve เป็น userId ฝั่ง client ก่อนส่ง) — กรองเฉพาะ annotation ของ userId นั้น ยกเว้น REDACTION ติดไปเสมอ (D4) และเติมชื่อผู้ใช้ (จาก user_name ที่ mirror ในตาราง) ท้ายชื่อไฟล์ …-annotated-<ชื่อ>.pdf; bookmarks outline กรองตามผู้บันทึกด้วย (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่ ลูกขึ้น root
    3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION ดำทึบ #000 ทับจริง → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
    4. COMMENT = sticky note จริงของ PDF (annotation dict /Subtype /Text — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): /Contents = message + replies ต่อท้ายทีละบรรทัด (— ชื่อ: ข้อความ, F9), /T = ผู้เขียน, /C สีเหลืองเดียวกับหมุดบนจอ, /M เวลาแก้ไขล่าสุด, /F 4 (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ note ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก /Contents, Acrobat คลิกเปิด popup)
    5. Bookmarks (N2) → PDF outline (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ /Outlines dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม parent_id เรียงตาม createdAt เหมือน sidebar, /Title เป็น PDFHexString (ภาษาไทยได้), /Dest [pageRef /XYZ x y null] จาก pageIndex + yOffset (แปลงผ่าน displayToPdfPoint) ใช้ตำแหน่ง yOffset เป๊ะ ๆ — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน scrollToBelowHeader แบบเดียวกับ goToAnnotation) ซึ่งตรงกับพฤติกรรม /XYZ ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์
    6. ตอบ application/pdf + Content-Disposition: attachment; filename="<title>-annotated.pdf"
  • ปุ่ม "Export PDF" บน toolbar → ลิงก์/window.open ไป route

F9 · A4 Reply/Thread

  • Actions createReply({ annotationId, userId, userName, message }) / deleteReply(id, userId) (เจ้าของ reply เท่านั้น) + publish table: "replies"
  • UI: popover ของ comment pin เพิ่มรายการ replies (ผู้เขียน + เวลา) + ช่องตอบ + ปุ่มลบ reply ของตัวเอง; sidebar แสดงจำนวน reply ต่อ comment
  • ลบ comment แม่ → replies หายอัตโนมัติ (CASCADE)

F10 · A5 Resolve

  • Action setAnnotationResolved(id, resolved) — ใครก็ได้ (D6), publish update event
  • UI: ปุ่ม toggle ใน popover + pin ที่ resolved เปลี่ยนเป็นสีเขียว/จางลง; sidebar แยกกลุ่ม "ค้างดำเนินการ / จัดการแล้ว" (เฉพาะ COMMENT)

F11 · C2 Export/Import JSON — ถอดออกแล้ว

ถอดออกจากโค้ดแล้ว (2026-09): route export-json, action importAnnotations/listFiles/ImportReport, UI ใน sidebar, และ countPdfPages ใน lib/server-pdf.ts — สเปกด้านล่างเหลือไว้เป็นประวัติเท่านั้น

  • Export: app/api/annotations/export-json/route.ts (GET ?fileId=) → { file: { title, exportedAt }, annotations: AnnotationDTO[], bookmarks: BookmarkDTO[] } (DTO ตรง ๆ — ISO string อยู่แล้ว)
  • Import: Server Action importAnnotations(targetFileId, payload, operatorUserId) — ทุกแถว validate ด้วย validator เดิม, สุ่ม id ใหม่ทุกแถว, คง author เดิม (D11), แถวที่ผิด (malformed / pageIndex เกินจำนวนหน้าไฟล์เป้าหมาย) → skip พร้อมนับ แล้วคืนรายงาน { imported, skipped }; publish create event ต่อแถวที่สำเร็จ — จำนวนหน้าไฟล์เป้าหมายอ่านฝั่ง server ด้วย pdf-lib จากไฟล์ใน public/ (อ่านอย่างเดียว + path guard เดียวกับ export route §5); dialog เลือกไฟล์เป้าหมายใช้ read-only action listFiles()
  • UI: ปุ่ม export ใน sidebar + ปุ่ม import เปิด dialog เลือกไฟล์เป้าหมาย (ไฟล์เดียวกัน หรือไฟล์อื่นจากรายการ) + <input type="file"> อ่าน JSON ฝั่ง client แล้วส่งเข้า action
  • URL: /annotate/[id]?page=N (1-based) และ/หรือ ?ann=<uuid>
  • PDFViewer effect แรก (หลัง pageSizes พร้อม) อ่าน window.location.search (ไม่ใช้ useSearchParams — เลี่ยงข้อจำกัด prerender ของ Next 14): scroll ไปตำแหน่ง absolute จาก placeholder (คำนวณครั้งเดียว smooth ครั้งเดียว เหมือน bookmark — rules/pdf-viewer.md); ถ้ามี ann → set selectedId ให้ด้วย
  • ปุ่ม "คัดลอกลิงก์" ใน sidebar รายการ annotation (ใช้กับ F15 หน้าสรุปด้วย)

F19 · A7 Version History

  • updateAnnotation → snapshot row เดิมเป็น version kind='update' ก่อนเขียนทับ (transaction เดียวกัน); deleteAnnotation → snapshot kind='delete' ก่อนลบ (D8 ทำให้ version รอดจาก CASCADE)
  • Action listVersions(annotationId) + restoreVersion(versionId) — ถ้า annotation ต้นทางยังอยู่: เขียนข้อมูลของ version กลับ row เดิม (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ → ย้อนกลับไป-มาได้) + publish update / ถ้าต้นทางถูกลบแล้ว (kind='delete' รอดจาก CASCADE ตาม D8): สร้าง annotation ใหม่ id ใหม่ + publish create (เดิมสร้างสำเนาใหม่ทุกกรณีจนเกิดรายการซ้ำบนหน้า)
  • UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง + ผู้แก้) + ปุ่มกู้คืน — แต่ละแถว kind='update' แสดงสรุปสิ่งที่เปลี่ยนผ่าน describeAnnotationChange (utils/annotation-display.ts — เพิ่มตามคำขอผู้ใช้ 2026-09-17): snapshot "ก่อนแก้" ของรอบนั้นเทียบกับ state ถัดไป (= snapshot ของรอบแก้ที่ใหม่กว่าถัดไป หรือค่าปัจจุบันของรายการสำหรับรอบล่าสุด) — สรุปเป็นบรรทัดสั้นต่อ field ที่เปลี่ยน (สีเป็นชื่อไทยจาก palette/ความหนา ×10/ความทึบ % ตามหน่วย UI, แก้ข้อความ/โน้ต, ย้ายตำแหน่ง/ปรับขนาดจาก bbox ทน noise 0.5%, DRAW ที่จุดเปลี่ยนแต่ bbox เดิม = "แก้เส้น" ของ eraser) ส่วนแถว kind='delete' เป็น snapshot สุดท้ายก่อนลบ ไม่ diff

ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)

  • F3 E2 คีย์ลัด: keydown listener ที่ window ใน PDFViewer — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo, Esc ปิด popup/deselect, DEL/Backspace ลบรายการที่เลือก (ของตัวเองเท่านั้น); ข้ามเมื่อ e.target เป็น input/textarea/contenteditable
  • F4 A3 ซ่อน–แสดงรายคน: state userFilter: "all" | "mine" | <userId> + dropdown รายชื่อ (จาก unique userId ใน annotations) — กรองทั้ง viewer และ sidebar ฝั่ง client ล้วน
  • F7 E1 สไตลัส/สัมผัส: InkLayer ใช้ Pointer Events + touch-none อยู่แล้ว — เพิ่ม: pressure (e.pressure → widths per-point, render เป็น segment เมื่อมี widths), palm rejection (เมื่อเคยเห็น pointerType === "pen" → ละเว้น touch ในหน้านั้น)
  • F13 A6 แก้ไขข้อความ comment: ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → updateAnnotation (reuse F1)
  • F14 D1 ค้นหา: Action searchAnnotations(fileId, q) — ilike บน data->>'text' และ data->>'message' (escape %/_ ใน q) — คีย์ text ครอบทั้ง HIGHLIGHT และ TEXTBOX อัตโนมัติ (ชื่อฟิลด์ใน JSONB ตรงกัน); กล่องค้นหาบน sidebar → ผลลัพธ์คลิก → goTo + select
  • F15 D2 หน้าสรุป: app/annotate/[id]/summary/page.tsx — server component รวมทุก annotation จัดกลุ่มตามหน้า (หน้า / ชนิด / ผู้เขียน / ข้อความ) + ปุ่มกระโดดเป็น deep link (F12); ทางเข้า = ปุ่มบน toolbar ของ viewer + ลิงก์กลับหน้าอ่านบนหน้าสรุปเอง; label/ข้อความย่อของแต่ละชนิดแชร์กับ sidebar ผ่าน utils/annotation-display.ts
  • F16 D3 Export CSV: app/api/annotations/export-csv/route.ts?fileId= — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอกดึงข้อมูลผ่าน N10 API แทน (route + ปุ่มหน้าสรุป + utils/csv.ts + test ถอดครบ)
  • F17 B2 Text box: tool textbox → คลิกบนหน้า → inline <textarea> วางบนตำแหน่งนั้น → บันทึก TEXTBOX; render เป็น div absolute (fontSize % → px); เจ้าของแก้ได้ (pattern F13); export วาดข้อความด้วย Sarabun (D5)
  • F18 B5 Stamp: preset คงที่ในโค้ด (STAMP_PRESETS เช่น "ได้รับแล้ว", "สำเนาถูกต้อง") → tool stamp → คลิกวางที่ตำแหน่ง; render เป็นกรอบข้อความสไตล์ตรายาง (scale % → px); export วาดกรอบ+ข้อความ (D7) — คำพิมพ์เอง (2026-09-28): dropdown ของ tool เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (state stampCustomMode, reset ทุกครั้งที่เปิดเมนูใหม่) คลิกแล้วสลับเป็นหน้า popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริงบนจอ (ความกว้างหน้าแรก × scale% — ใหญ่เกินเมนูย่อทั้งชุด, กรอบ/ฟอนต์/การจัดวางสูตรเดียวกับ render จริง) — คำพิมพ์ live ไปที่ stampPreset เดียวกับ preset, ปุ่ม "ใช้ตรายางนี้"/Enter = ปิดเมนูแบบเลือกค่าจบ (setState ตรง ไม่ผ่าน markMenuClosed), ปุ่ม tool แสดง label "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor ใน TextboxStampLayer — textarea WYSIWYG ใช้ editingId/commitEdit ร่วมกับ TEXTBOX; จำกัดความยาว STAMP_MAX_LABEL_CHARS = 50) · จัดบรรทัดแบบถอยหลัง (2026-09-28 รอบสอง): สูตรจัดวางย้ายรวมที่ utils/geometry.ts — stampLayout(label, w, measure): คำสั้น = บรรทัดเดียวฟอนต์เต็ม, คำยาวพอดี ๆ = ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น วัดครั้งเดียว) ลงได้จนถึง STAMP_FONT_FLOOR_RATIO=10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% พอ = บรรทัดเดียว — คำขอผู้ใช้), คำยาวเกิน floor = ฟอนต์กลับไปใหญ่แล้ว wrap (wrapStampLabel — เว้นคำที่ space, คำเดี่ยวยาวตัดที่อักษร) หาจากใหญ่ไปเล็ก (×0.92/รอบ) จนได้ ≤ STAMP_MAX_LINES=3 บรรทัด (เกินบรรทัดยอมแลกกับฟอนต์ที่ยังอ่านออก), stampBoxHeight คุมความสูงกรอบ (1 บรรทัด = 0.4w เป๊ะเหมือนเดิม, หลายบรรทัดสูงขึ้นตามจำนวนบรรทัด) — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — ใช้ร่วมทั้ง render/preview/StampEditor ด้วย measureStampText (canvas), export ด้วย pdf-lib widthOfTextAtSize และ bbox (annotationBBox รับ optional measure — ฝั่ง client ทั้งหมด: กรอบ selection/hit-test/tooltip/eraser ส่ง measureStampText ให้ตรง render จริง [bug ขอบล่างกรอบเกินตรายาง 2026-09-28], ฝั่ง server: export sticky note + describeAnnotationChange ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58) · stampFitFontSize/constants เดิม re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด · มุมเอียง (2026-09-28): StampData.rotation? องศา −180..180 หมุนรอบกลางกรอบ — ช่อง "มุมเอียง" ในแผง selection (patch stampRotation), render+กรอบ selection หมุนด้วย CSS transform, annotationBBox คืน AABB ของกรอบหมุน, handle scale ใช้ anchor มุมหลังหมุน + k ใน frame ของกรอบ, export ใช้ pdf-lib degrees(−θ) + anchor มุมล่างซ้ายหลังหมุน — ทุก patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (stampScale/label เมื่อ rotation ≠ 0) ต้อง clampStampIntoPage กลับเข้าหน้าทันทีเหมือนตอนตั้งมุม และ validator ฝั่ง server (ยึด stampLayoutEstimate ต่างจาก client ที่ยึด measure จริง) ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้ากลืนความคลาดฟอนต์ต่อเครื่อง (code review 2026-09-29)

4. ลำดับ Task + เกณฑ์ Verify (skill /implement-feat อ้างตารางนี้)

ทำตามลำดับเลข F1→F19 (dependency จัดไว้แล้วในลำดับนี้) — ทุก task: npx tsc --noEmit ต้องผ่าน + เรียก agent plan-reviewer; verify แบบ realtime ใช้ 2 browser แท็บ; แตะ schema → /sync-db

Task FEAT งานหลัก ต้องมีก่อน Verify เพิ่มเติม
F1 A1 updateAnnotation + update event + utils/geometry.ts + AnnotationSelectionLayer – ลากย้าย pin/เส้น/กรอบไฮไลต์แล้วตำแหน่งถูกต้อง ทุก zoom และหลัง reload; แก้ของคนอื่นถูกปฏิเสธ; แท็บ B เห็นการเปลี่ยนแปลงแบบ realtime
F2 A2 hooks/useHistory.ts + ปุ่ม Undo/Redo F1 undo วาด/ลบ/แก้ได้ย้อนลำดับ; redo; แท็บ B วาดเข้ามา → แท็บ A กด undo ต้องไม่ทำอะไร
F3 E2 คีย์ลัด H/D/C/B, Ctrl+Z/Ctrl+Shift+Z, Esc F2 พิมพ์อยู่ใน composer/textarea แล้วกดปุ่มตัวอักษรต้องไม่สลับ tool
F4 A3 filter รายคน (viewer + sidebar) – เลือกดูเฉพาะตัวเอง/รายคน/ทั้งหมดได้; กรองไม่แตะข้อมูลใน DB
F5 B1 enum+ShapeData+validation+InkLayer รูปทรง+tool shape – /sync-db สำเร็จ; วาด 4 ชนิดได้ครบ; สลับ zoom แล้วสัดส่วน/ตำแหน่งไม่เบี้ยว; แท็บ B เห็น realtime
F6 B3 tool erase + hitTestPath F5 ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ
F7 E1 pressure + palm rejection F5 tsc ผ่าน; ค้างทดสอบด้วยมือ บนอุปกรณ์สัมผัส/สไตลัส: วาดด้วยนิ้วได้ ไม่ scroll ระหว่างวาด, ปากกากับนิ้วพร้อมกันนิ้วไม่รบกวน
F8 B4+C1 enum REDACTION + export route (pdf-lib + fontkit + Sarabun) – /sync-db; วาด redaction บังข้อความบนจอ; export แล้วเปิดไฟล์ใหม่: ทุกชนิดติดไปถูกหน้าถูกตำแหน่ง, redaction เป็นดำทึบทับข้อความจริง, COMMENT เป็น sticky note /Text เปิด popup อ่านได้ (message+replies), bookmarks โผล่เป็น outline แผง Bookmarks ของ reader; hash ไฟล์ต้นฉบับใน public/ ไม่เปลี่ยน; ไม่มี Sarabun → export ยังสำเร็จ (fallback กรอบ)
F9 A4 ตาราง annotation_replies + actions + popover เธรด – /sync-db; ตอบ comment เป็นเธรดได้; แท็บ B เห็น reply realtime; ลบแม่ → replies หาย (CASCADE)
F10 A5 columns resolved/resolved_at + action + UI จาง/แยกกลุ่ม – /sync-db; toggle แล้ว pin จาง/เขียว + sidebar แยกกลุ่ม; ใครก็ได้ toggle (D6); realtime
F12 C4 อ่าน ?page=/?ann= ตอน mount F1 เปิด URL → เลื่อนไปหน้า/เลือก annotation นั้น; ไฟล์ 44 หน้ากระโดดหน้าไกลได้ไม่ jump (placeholder ขนาดจริง)
F13 A6 ปุ่มแก้ไขข้อความ comment ใน popover F1 เจ้าของแก้ได้; ของคนอื่นไม่มีปุ่มแก้; แท็บ B เห็นข้อความใหม่ realtime
F14 D1 searchAnnotations + กล่องค้นหา sidebar – หาคำไทยเจอทั้ง highlight text และ comment message; คลิกผลลัพธ์ → ไปตำแหน่ง + เลือก; ไม่พบแสดง "ไม่พบ"
F15 D2 หน้า /annotate/[id]/summary F12 แสดงทุก annotation จัดกลุ่มหน้า; ปุ่มกระโดด (deep link) ทำงานจริง
F16 D3 route export CSV F15 เปิด CSV ใน Excel ภาษาไทยอ่านออก (BOM)
F17 B2 enum TEXTBOX + tool + render + export F8 /sync-db; พิมพ์ข้อความลงหน้าได้; fontSize % → px ถูกทุก zoom; เจ้าของแก้ได้; ติดไปกับ export
F18 B5 enum STAMP + preset + tool + export F8 /sync-db; เลือก preset คลิกวางได้; scale % ถูกทุก zoom; ติดไปกับ export
F19 A7 ตาราง annotation_versions + snapshot + restore F1 /sync-db; แก้ 2 ครั้ง → ประวัติ 2 versions; ลบ → เกิด version kind delete และไม่หายไปกับ CASCADE; restore → กลับมาเป็น id ใหม่ + realtime

Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 ← F12 · F16 ← F15 · F17/F18 ← F8 (ใช้ util วาดข้อความ/ฟอนต์จาก export)


5. Edge Cases ที่ออกแบบไว้ล่วงหน้า

  • Update ชน SSE: replace by id เป็น idempotent — SSE update มาถึงก่อน/หลัง action เสร็จได้ผลเดียวกัน; ห้ามเพิ่ม logic กัน duplicate เกิน dedupe/replace ด้วย id (คงคู่กับ useOptimistic เหมือนเดิม)
  • Undo ข้ามผู้ใช้: history เก็บเฉพาะ action ของตัวเอง — event realtime ของคนอื่นไม่ push เข้า stack; undo(create) ที่ row หายไปก่อน (คนอื่นลบ) → ข้ามพร้อม console
  • Eraser กับรูปทรง: hit-test เฉพาะเส้น stroke/ขอบ — ผ่านพื้นที่รูปทรงไม่โดนลบ
  • Selection handles กับ tool ที่ InkLayer ใช้ pointer (F6): เมื่อ tool เป็น draw/shape/erase ให้ปิด handle มุม/ขอบกรอบ/แถบเครื่องมือของ AnnotationSelectionLayer (pointer-events none กลับไปเหมือน container — เช่นเดียวกับ pins) — กันลาก eraser โดน strip ขอบกรอบแล้วกลายเป็น move-drag; ตัวกรอบ selection เป็น none อยู่แล้วจึงยังมองเห็นได้ปกติ
  • ปุ่มเปลี่ยนความหนา (F1) กับเส้นที่มี widths (F7): ตอนเปลี่ยน strokeWidth หลักของเส้น DRAW ที่มี widths ต้อง scale widths ทุกจุดตามอัตราส่วน w / strokeWidth เดิมด้วย — ไม่เช่นนั้น render ใช้ widths ต่อจุดล้วนแล้วปุ่มดูเหมือนใช้ไม่ได้
  • Redaction บนจอ ≠ ปิดพรางจริง: TextLayer ยัง select ข้อความใต้ redaction ได้ (PDF ต้นฉบับไม่ถูกแก่ — non-destructive) — ความปลอดภัยจริงอยู่ที่ไฟล์ export เท่านั้น (D4) → ใส่ hint สั้น ๆ ใน UI ตอนเลือก tool redaction
  • Export กับหน้า /Rotate: แปลงพิกัด % ตามมุมหมุนก่อนวาด — ทดสอบด้วย fixture ที่ pdf-lib สร้างขึ้นเอง (หมุน 90°) เพราะไฟล์ตัวอย่างใน public/samples/ ไม่มีหน้าหมุน
  • Path traversal ใน export route: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น
  • Deep link กับ virtualization: placeholder มีขนาดจริงทุกหน้า → คำนวณ absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark)
  • คีย์ลัดกับฟอร์ม: ตรวจ e.target เป็น input/textarea/contenteditable → ข้ามทุก shortcut
  • Font ไทยใน export: ไฟล์ Sarabun ขนาดใหญ่ (~100KB+) embed ครั้งเดียวต่อเอกสาร (subset ไม่จำเป็นเฟสนี้)
  • SSE event ใหม่กับ hook เดิม: deploy พร้อมกันทั้ง server+client (single app) — ไม่มีปัญหา version skew
  • TEXTBOX/STAMP geometry (F17/F18): position คือมุมซ้ายบนของกล่อง; TEXTBOX render กว้างสุด 50% ของหน้า (wrap อัตโนมัติ), STAMP กว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง (สีตรายาง/ข้อความคงที่แชร์ระหว่าง render กับ export ผ่าน utils/annotation-display.ts); translate/scale ของ F1 แตะเฉพาะ position — fontSize/scale คงเดิมตามแนวทาง "strokeWidth คงเดิม" ของ F1 (ลากมุม = เลื่อน position เท่านั้น); bbox ของ TEXTBOX ประมาณจากข้อความ (อักษรกว้าง ~0.6 × fontSize, wrap ที่ 50% ของหน้า — ค่าประมาณเพื่อกรอบ selection พอ ไม่ต้องตรงเป๊ะ), bbox ของ STAMP คำนวณจากสูตร render จริง; eraser (F6) hit-test เฉพาะ DRAW/SHAPE อยู่แล้วจึงไม่ลบ TEXTBOX/STAMP
  • Version snapshot (F19): เก็บ page_index ของ annotation ตอน snapshot — restoreVersion เขียนกลับ row เดิม (หรือสร้างใหม่ หน้าเดิม เจ้าของเดิม เมื่อต้นทางถูกลบไปแล้ว) จากข้อมูลใน snapshot; ปุ่ม "ประวัติ" แสดงเฉพาะรายการของตัวเองฝั่ง UI ตามสเปก (action restoreVersion(versionId) ไม่มี guard userId ตาม signature ในแผน)
  • Dialog (fixed) ที่ render ใน sidebar (F19): aside.sticky ของ sidebar สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ fixed z-50 แพ้ textLayer (z-[2] ระดับ root) จนปุ่มใน dialog คลิกไม่ได้ (พบตอน verify ด้วยเบราว์เซอร์จริง) → dialog ต้อง render ผ่าน createPortal ไป document.body พร้อม guard typeof document === "undefined" เพราะ Next ก็ SSR client component (เข้าถึง document ตอน SSR = 500)
  • F19 กู้คืนรายการที่ถูกลบ: action รองรับ (version kind='delete' รอดจาก CASCADE ตาม D8) แต่ UI เข้าถึง dialog ได้เฉพาะจากปุ่ม "ประวัติ" ของรายการที่ยังอยู่ใน sidebar — รายการที่ถูกลบไปแล้วจึงยังไม่มีทางเข้าถึงจาก UI (ทำตามสเปก §3 F19) หากต้องการกู้คืนของที่ถูกลบ ให้เปิดงานต่อยอดใหม่

6. Invariant & Rules Sync (ทำพร้อม task — ตามกติกา CLAUDE.md: แก้ invariant ที่แผนก่อน แล้ว sync มา rules)

Task ไฟล์ที่ต้องอัปเดตพร้อมงาน
F1 CLAUDE.md (Out of Scope: edit/ย้าย อนุญาตตามแผนนี้) · docs/PLAN.md §9 (หมายเหตุถูกขยายโดยแผนนี้) · rules/realtime.md (update event) · rules/coordinates.md (geometry utils)
F2 CLAUDE.md (Out of Scope: undo/redo อนุญาต)
F5 / F8 / F17 / F18 rules/data-layer.md (enum + union member ใหม่) · F8 เพิ่ม: CLAUDE.md invariant #1 หมายเหตุ "export สร้างไฟล์ใหม่ ไม่แตะต้นฉบับ" + Out of Scope export อนุญาต
F9 / F10 / F19 rules/data-layer.md (ตาราง/columns ใหม่) · rules/realtime.md (replies event + resolved ใน update row)
F12 rules/pdf-viewer.md (deep link อ่าน query ตอน mount)

แก้/เพิ่ม invariant ที่ไม่ได้ครอบไว้ในตารางนี้ → อัปเดต ไฟล์นี้ ก่อนเสมอ แล้วค่อยแตะโค้ด/rules


7. ฟีเจอร์เสริมหลังแผนเดิม (request นอก F1–F19)

N1 · โน้ตคำอธิบาย (note) ฝังใน annotation — เช่น ลากเส้นแล้ว บอกเหตุผลที่วาดเส้นนั้น

  • ข้อมูล: field note?: string ใน JSONB data ของทุกชนิดยกเว้น COMMENT (HighlightData/DrawData/ShapeData ทั้ง 2 variant/RedactionData/TextboxData/StampData) — COMMENT ไม่มีเพราะ message ทำหน้าที่นี้อยู่แล้ว — validator (vNote) trim ค่า ว่าง/ช่องว่างล้วน = ไม่มีโน้ต (ถูกตัดออกจาก object ก่อนเก็บ)
  • แก้ไข: ใน sidebar รายการหมายเหตุ — ปุ่ม StickyNote ของรายการตัวเอง (ยกเว้น COMMENT) เปิด textarea inline ใต้แถว (Ctrl/Cmd+Enter บันทึก, Esc ยกเลิก) → ผ่าน updateAnnotation เดิม (optimistic + version snapshot F19 + undo/redo F19/F2 อัตโนมัติ) เห็นการเปลี่ยนแปลง realtime ผ่าน SSE update event
  • แสดงผล (ทุกคนเห็น): sidebar (ใต้รายการ, clamp 2 บรรทัด) · หน้าสรุป F15 (บรรทัด "โน้ต: …") · export CSV F16 (คอลัมน์ "โน้ต") — อ่านค่าผ่าน annotationNote(a) ใน utils/annotation-display.ts (ใช้ร่วมทั้ง 3 ที่ตาม pattern เดิม)
  • ค้นหา (F14): searchAnnotations เพิ่มเงื่อนไข data->>'note' ILIKE ควบคู่ text/message — ค้นคำในโน้ตเจอด้วย
  • Tooltip บนหน้า: ชี้ mouse เหนือ annotation ใด ๆ บนหน้า (ยกเว้น COMMENT ที่มี popover ของ pin) → tooltip "ชนิด · ชื่อผู้ทำ + โน้ต (ถ้ามี)" ลอยข้าง bbox — ตรวจด้วย hitTestAnnotation ชุดเดียวกับการคลิกเลือกบน onMouseMove ของ page wrapper, เก็บ state เป็น id เดียว (hoveredId) เปลี่ยนเฉพาะเมื่อรายการเปลี่ยน (ไม่ re-render ทุก move, hit-test เดินจากท้าย array โดยไม่สร้าง array ใหม่), pointer-events: none + ปิดช่วง tool ที่รับ pointer (draw/shape/erase/redact/pan), ช่วงกดปุ่มเมาส์ค้าง (e.buttons !== 0) และช่วง popover ของ comment pin เปิดอยู่ (openPinId !== null — กัน tooltip ลอยทับ popover)
  • ประวัติแก้ไขล่าสุด (ถอดกลับ): เคยทดลองเป็นเธรด reply บนรายการที่เลือก (reuse annotation_replies + ปุ่ม 💬) แล้วถูกถอดออกตาม feedback — ใช้ field note ฝังใน annotation แทน

N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ)

  • ข้อมูล: ตาราง bookmarks เพิ่ม parent_id uuid NULL (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — ไม่ทำ FK เพราะลบแม่ต้อง re-parent ลูกเอง) + note text NULL (โน้ตย่อยของ bookmark) — จำกัด 3 ระดับ (BOOKMARK_MAX_DEPTH = 3): create ต้องมี depth(parent) < 3, ย้ายต้องผ่าน depth(parent) + subtreeHeight(ตัวเอง) ≤ 3 + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม .for("update") ทุกแถวที่เดินผ่าน (บรรพบุรุษ/ลูกหลาน — helper lockedDepth/lockedSubtreeHeight/isDescendant) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า
  • การสร้าง: ปุ่ม 🔖 บน toolbar (และคีย์ลัด B) เปิด popup ทุกครั้ง — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ — ช่องชื่อ/โน้ตดักความยาวด้วย maxLength ตาม limit ของ server จาก utils/bookmark-limits.ts: ชื่อ 120 / โน้ต 200 ตัวอักษร ไม่รอ error ตอนบันทึก) + บรรทัดบอกจุดที่จะชี้ (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม + ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark หน้าที่ viewer ดูอยู่ เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
  • ชื่อ default จากข้อความที่คลุม: viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน mouseup (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน normalizeBookmarkTitle) ไม่มีข้อความที่คลุม = "หน้า N"
  • ปุ่ม bookmark ในแผง selection (F1 toolbar): ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N")
  • แก้ไข (✏ เจ้าของเท่านั้น): inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)
  • การลบ: ลบ sub = เหมือนเดิม · ลบ หัวข้อที่มี sub = sub ทั้งหมดขยับขึ้นระดับบนสุด (ไม่มีข้อมูลหาย) — re-parent + delete ใน transaction เดียว + publish update event ต่อลูก
  • การแสดง (sidebar tab Bookmarks): ต้นไม้ 3 ระดับ (indent ตามระดับ 1/2/3), หัวข้อมี chevron กาง/หุ่ม (state local ต่อ client) + จำนวน sub (n), โน้ตแสดงใต้ชื่อ (clamp 2 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตามลำดับหน้า (N5)
  • Realtime: SSE event ของ bookmarks เพิ่ม action update (แก้ชื่อ/โน้ต/ย้าย + ลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — client replace by id
  • Out of scope ของ N2: drag เพื่อจัดลำดับ/ย้าย, sub ลึกเกิน 3 ระดับ, bookmark ในหน้าสรุป/CSV

N3 · รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) เป็นเอกสารที่ annotate ได้เหมือน PDF (2026-09-15)

แผนงานฉบับเต็ม: docs/PLAN-IMAGE.md — requirement 5.3.4

  • ข้อมูล (ไม่แตะ schema): files.pdf_url เก็บ path รูปใน public/ ได้เหมือน PDF — ชนิดไฟล์ derive จาก extension ผ่าน utils/file-kind.ts (fileKindFromUrl/imageFormatFromUrl/imageContentTypeFromUrl) ใช้ร่วม viewer/export/หน้ารายการ/seed — annotation/bookmark ใช้พิกัด % + pageIndex 0-based เหมือนเดิม (TIFF หลายเฟรม = หลายหน้า เหมือน PDF หลายหน้า) — invariant #1/#2/#10 ครอบคลุมรูปอัตโนมัติ
  • การโหลด (viewer): jpg/png โหลดจาก static pdfUrl ตรง ๆ (ไม่ผ่าน /api/pdf — ไม่มี ToUnicode ให้ซ่อม) ด้วย new Image() + decode() ขนาดจาก naturalWidth/Height (browser คิด EXIF orientation ให้แล้ว); TIFF (utils/tiff-client.ts): fetch bytes → utif2 (dynamic import เฉพาะ tif ตาม pattern invariant #6) decode ทุก IFD → toRGBA8 → canvas (หมุนตาม orientation tag 274, cap ด้านยาว 4096px กัน memory) → PNG blob URL ต่อเฟรม — revoke ทุก blob URL ตอน unload/สลับไฟล์; ทุกหน้ารูป normalize กว้าง 595 สัดส่วนเดิม (scale contract กับ export) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4
  • การแสดง: prop content: PageContent (union pdf | image) ใน PdfPage — รูป: <img> ที่ z-1 pointer-events:none เหมือน canvas (invariant #3 — DOM order ไม่ขยับ, textLayer เป็น conditional render เฉพาะ pdf), ไม่มี TextLayer → เรียก onTextLayerRendered(pageIndex, 0) ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง; toolbar คงทุก tool รวม HIGHLIGHT (คลุมข้อความบนรูปไม่ได้ = no-op มี hint อธิบาย); เฟรม TIFF ที่ decode พัง = placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark/deep link เสถียร ไฟล์ไม่พังทั้งเล่ม
  • Export PDF (ต่อยอด F8): branch ก่อน PDFDocument.load → imageDocFromBytes (utils/image-to-pdf.ts): PDFDocument.create + embedJpg/embedPng + addPage([595, 595·h/w]) + drawImage เต็มหน้า (rotation=0) แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด; TIFF decode ทุกเฟรม → หมุน/mirror ครบทุก orientation 1–8 (rotateRgba — ตรงกับฝั่ง client) → PNG (pngjs) → embedPng (เฟรมพัง = หน้าขาวให้ pageIndex map ตรง); JPEG EXIF orientation: exact สำหรับ 3/6/8 (parse APP1 → swap กว้าง-สูง + drawImage rotate) — mirror 2/4/5/7 เป็น known limitation (pdf-lib วาด mirror ไม่ได้ และพบน้อยมากในไฟล์สแกน — มักเจอจากกล้องหน้ามือถือ: 5/7 ประมาณด้วยการหมุนของ 8/6, 2/4 แสดงตรง ๆ); /api/pdf เพิ่ม defensive branch คืน bytes ดิบ + content-type ถูกสำหรับรูป (กัน stray call เข้า fixPdfActualText)
  • Realtime: ไม่มี event/schema ใหม่ — SSE ผูก fileId เหมือนเดิม
  • หน้ารายการ/seed: หน้า / แสดงไอคอน + badge ชนิด (PDF/JPG/PNG/TIFF); scripts/seed-samples.mjs sync ทุก extension ใน SAMPLE_FILE_EXTENSIONS
  • Out of scope ของ N3: รูปแบบอื่น (gif/webp/bmp/heic), หมุน/ครอปรูปใน viewer, OCR, TIFF CCITT G3/G4 + JPEG-in-TIFF (เฟรมพัง = placeholder เทา), แปลงหรือเขียนไฟล์ต้นฉบับเด็ดขาด

N4 · อัปโหลดไฟล์แบบง่าย (2026-09-15)

เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ช่อง "กลุ่ม typeId" ทางเลือกในฟอร์ม (ไม่กรอก = ไม่มีกลุ่ม) — ไฟล์อัปโหลดเข้ากลุ่ม N9 ได้โดยไม่ต้องผ่านลิงก์ e-Court; normalize เหมือน N8 (trim → ว่าง = NULL, cap 200 — normTypeId mirroring normGroup)

  • UI: ฟอร์มเดียวบนหน้า / (components/UploadFileForm.tsx) — <input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"> + ช่อง "กลุ่ม typeId" ทางเลือก (uncontrolled ส่งไปกับ FormData) + ปุ่มอัปโหลด (useTransition, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
  • Server Action uploadFile (app/actions/files.ts): รับ FormData — validate extension (whitelist เดียวกับ SAMPLE_FILE_EXTENSIONS) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน public/uploads/<uuid>.<ext> (ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น — กันชนกันและกัน path traversal; title = ชื่อไฟล์เดิมที่ sanitize) → insert files (title, pdf_url, type_id) (typeId จากช่องกลุ่ม — ว่าง = NULL, insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีทางแก้กลุ่มไฟล์เดิม) → revalidatePath("/") — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
  • next.config: experimental.serverActions.bodySizeLimit = "100mb" (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
  • เสิร์ฟไฟล์อัปโหลดผ่าน app/uploads/[name]/route.ts (GET — อ่าน public/uploads/<uuid>.<ext> + path guard ของ lib/server-pdf.ts + content-type จาก utils/file-kind.ts): Next.js production (next start/standalone) ไล่รายชื่อไฟล์ใน public/ เก็บใน Set ครั้งเดียวตอน startup (router-utils/filesystem.js setupFsCheck) — ไฟล์ที่ action เขียนตอน runtime GET ตรง ๆ แล้ว 404 (dev ไม่เป็น → อาการ "local ผ่าน Docker พัง"); ไฟล์ที่ bake ตอน build (pdf.worker/ฟอนต์/samples) ถูกเสิร์ฟโดย static path ก่อน route นี้อยู่แล้วตามลำดับ routing; PDF ใน viewer โหลดผ่าน /api/pdf อยู่แล้ว ตัวที่ต้องใช้ route นี้จริง = รูปภาพ (<img src={pdfUrl}>)
  • ความสัมพันธ์กับ seed: seed-samples จัดการเฉพาะ /samples/% — ไฟล์อัปโหลดใน /uploads/ ไม่ถูก seed ลบ
  • Out of scope ของ N4: หน้าจัดการ/ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file พร้อมกัน, auth ผูกเจ้าของไฟล์, สิทธิ์ต่อไฟล์

N5 · เรียง bookmark ตามลำดับหน้า (2026-09-16)

แผนงานฉบับเต็ม: docs/PLAN-BOOKMARK.md — requirement 5.3.11

  • การเรียง: ต้นไม้ bookmark ใน sidebar (root และ sub ทุกระดับ) เรียงตาม หน้า (pageIndex) → ตำแหน่งบนหน้า (yOffset บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic) — comparator byPageOrder ใน AnnotationSidebar.tsx sort ตอน render ฝั่ง client เท่านั้น (realtime แทรก row ต่อท้าย state — เชื่อลำดับจาก server query อย่างเดียวไม่ได้); ไม่มีปุ่มสลับโหมด
  • ไม่แตะ: query ฝั่ง server (ยัง orderBy createdAt — client sort ทับเสมอ), export outline, หน้าสรุป F15, CSV
  • Out of scope ของ N5: ปุ่มสลับกลับไปเรียงตามเวลาสร้าง, drag จัดลำดับ (ของเดิมใน N2), แสดงชื่อผู้บันทึกในแถวของต้นไม้ปกติ

N6 · ค้นหา bookmark พร้อมเงื่อนไข (2026-09-16)

แผนงานฉบับเต็ม: docs/PLAN-BOOKMARK.md — requirement 5.3.12

  • ข้อมูล: ตาราง bookmarks เพิ่ม user_name text NOT NULL DEFAULT '' (mirror annotations — เดิมมีแค่ user_id จนกรองตามผู้บันทึกแล้วไม่มีชื่อจะแสดง) — createBookmark รับ+validate userName เพิ่ม; row เดิม backfill จาก annotations (จับคู่ user_id) ครั้งเดียวหลัง /sync-db ตาม SQL ในแผน; ค่า '' = แสดง "ไม่ทราบชื่อ" ฝั่ง UI
  • 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 (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search

N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17)

แผนงานฉบับเต็ม (ตอนเป็น PDF): docs/PLAN-BOOKMARK-PDF.md — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF · ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอกดึงข้อมูล bookmark ผ่าน N10 API แทน (route app/api/bookmarks/export/ + ปุ่มใน sidebar ถอดครบ; รายละเอียดข้างล่างเป็น historical record)

  • Route: app/api/bookmarks/export/route.ts (GET ?fileId=) — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1): validate fileId (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query bookmarks ทั้งไฟล์ orderBy asc(pageIndex), asc(yOffset), asc(createdAt) (ลำดับเดียวกับ sidebar/N5) → response JSON { fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] } — item เป็น toBookmarkDTO ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ parentId ครบ lossless reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = "bookmarks": [] (ไม่ 404 — ปุ่มทำงานเสมอ); content-type: application/json; charset=utf-8 + attachment + filename* จาก encodeRFC5987("{title}-bookmarks.json") (pattern F8/F16)
  • UI: ปุ่มไอคอน Braces ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — <a href="/api/bookmarks/export?fileId="> ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + coarse-36 — ส่งออกทุก bookmark ของไฟล์เสมอ (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
  • Read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB — เหมือน searchBookmarks/route export เดิม)
  • Out of scope ของ N7: export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม docs/PLAN-BOOKMARK-PDF.md), export ข้ามหลายไฟล์ (gap 5.3.10)

N8 · เปิดเอกสารจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= (2026-09-22 · implement แล้ว)

แผนงานฉบับเต็ม: docs/PLAN-ECOURT.md

  • สัญญาลิงก์: fileUrl จำเป็น + fileId แนะนำ (dedupe key external_id = "ecourt:<fileId>", ไม่มี fileId fallback "url:<href>") + typeId แนะนำ (รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน — ส่งต่อให้ N9 docs/PLAN-DOCSET.md จัดกลุ่มตอนแสดงผล)
  • ไม่เก็บสำเนาไฟล์ (metadata-only): ลง DB แค่ title (จาก basename ของ URL) + external_id + URL เต็มใน pdf_url + type_id — ตอนเปิดดู/export server ดึงไฟล์สดจาก e-Court ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) — invariant #1 ครบ
  • หลักเวอร์ชัน — id เปลี่ยน = ไฟล์ใหม่: fileId เดิม + URL เปลี่ยน (Temp link อายุใหม่) = row เดิม annotation ครบ + refresh pdf_url; fileId ใหม่ = row ใหม่เริ่ม blank ไม่สืบทอด; ลิงก์ส่ง typeId มา = ย้ายกลุ่มตามค่าล่าสุด (ไม่ส่ง = คงเดิม ห้ามเคลียร์)
  • Entry point 2 จุด: หน้า / และ /annotate อ่าน query แล้ว registerExternalFile (lib/external-import.ts — dedupe + catch 23505 พร้อมกัน 2 แท็บ) → redirect("/annotate/<uuid>") — เรียกระหว่าง render ของ server component ไม่ใช่ Server Action (carve-out invariant #4: หน้า force-dynamic + redirect ออกทันที ไม่ต้อง publish event/revalidatePath) / fail = กล่อง error ไทย role="alert" เหนือ section เพิ่มเอกสาร หน้ารายการยังแสดง
  • Validation (pure utils/external-file.ts): allowlist โฮสต์ admincourt.go.th (suffix match กัน admincourt.go.th.evil.com/xadmincourt.go.th) + env EXTERNAL_FILE_HOSTS เพิ่มได้, https: เท่านั้น, ห้าม user:pass@ — และรับ path ในเครื่อง (/samples/… /uploads/… — decode + ห้าม .. + ตรวจไฟล์มีจริง) เพื่อทดสอบ grouping ใน dev + เติม https:// ให้ URL ที่ไม่มี scheme
  • ดึงไฟล์สด (lib/remote-file.ts): fetchSourceBytes — validate ซ้ำชุดเต็มหลัง redirect (res.url), Content-Length pre-check + นับ byte ระหว่าง stream จำกัด 100MB, timeout 60s, guard body null/0 ไบต์ — ใช้ 2 จุด: /api/pdf และ /api/annotations/export (F8) ก่อนเข้า pipeline เดิม
  • Viewer: withBasePath ปล่อย absolute http(s):// ผ่านเฉย ๆ (invariant #12), PDF branch โหลดผ่าน /api/pdf?fileId= อยู่แล้ว, รูปรีโมต (N3) เปลี่ยนไปโหลดผ่าน /api/pdf?fileId= ด้วย (แยกทางด้วย isHttpUrl)
  • Login วนกลับ (D5): middleware.ts เดิมล้าง query ทิ้ง — แก้เป็นเก็บ next = pathname + search แนบไป /login (เฉพาะ next !== "/") → หน้า login sanitizeNextPath (เฉพาะขึ้นต้น / และไม่ใช่ ////\ — กัน open-redirect) → มี session แล้ว redirect(next ?? "/") / login ผ่าน LoginForm router.replace(next ?? "/") — deep link ของคนยังไม่ login ไม่หาย
  • การ์ดจำลองลิงก์: components/EcourtLinkForm.tsx บนหน้ารายการ / (กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" → router.push("/annotate?fileUrl=&fileId=&typeId=")) + แสดงลิงก์เต็มสด ๆ — ทดสอบก่อนมีลิงก์จริงจาก e-Court
  • Out of scope ของ N8: auth/credential ต่อ e-Court (ส่งต่อ token/cookie ถ้าปลายทางต้องมี session จริง), ระบบหลายเวอร์ชัน + สืบทอด annotation ข้ามเวอร์ชัน, หน้าจัดการ/ลบ/แก้ typeId ด้วยมือใน UI (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9), แคชสำเนาไฟล์รีโมตลงดิสก์, โฮสต์นอก allowlist, API ดึงข้อมูลกลับให้ e-Court (N10 docs/PLAN-API.md) — known risk ที่รับไว้: /api/pdf + export เป็น proxy ไม่ต้อง login (รู้ fileId = ดึง bytes ได้)

N9 · กลุ่มเอกสารตาม typeId จาก e-Court + หน้า /groups/[typeId] (2026-09-23)

แผนงานฉบับเต็ม: docs/PLAN-DOCSET.md — ต่อยอด type_id ที่ N8 เขียนตอน registerExternalFile

  • กลุ่มระดับเดียว กำหนดด้วย files.type_id (จากลิงก์ e-Court ?typeId= — เขียนที่ registerExternalFile ของ N8 เท่านั้น: insert ครั้งแรก + ย้ายกลุ่มเมื่อลิงก์รอบใหม่ส่ง typeId คนละค่า, ไม่ส่ง = คงเดิม ห้ามเคลียร์) — ไม่มีตารางกลุ่ม/many-to-many/หลายระดับ: กลุ่ม = ค่า distinct ของ type_id ณ ตอน query, แสดงผลด้วยรหัสกลุ่มโดยตรง (ไม่มีคอลัมน์ชื่อกลุ่ม — ถอด type_name ตามคำขอผู้ใช้) — ไฟล์ seed/อัปโหลด N4/ลิงก์ไม่มี typeId = NULL เสมอ
  • หน้ารายการ / จัดกลุ่ม: section ต่อกลุ่ม (type_id ไม่ NULL) เรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง createdAt asc เหมือนเดิม + ปุ่ม Annotate เดิม; หัวกลุ่ม = รหัสกลุ่ม + จำนวนไฟล์ + ลิงก์ "เอกสารในกลุ่ม" → /groups/{encodeURIComponent(typeId)}; ไฟล์ type_id NULL → section "ไม่มีกลุ่ม" ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร — แสดงเสมอถ้ามีไฟล์) — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม ไม่มี client component/action ใหม่
  • หน้า /groups/[typeId] (server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว): query files where type_id orderBy createdAt — ไม่มี = 404 (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ / + กล่อง info "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (ถอดออกตามคำขอผู้ใช้ 2026-09-24); รายการ = ชื่อไฟล์ + วันที่นำเข้า (formatThaiDate) + จำนวน annotation/bookmark + ปุ่ม Annotate → /annotate/{fileId}; การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ groupBy นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link /annotate/{fileId}?ann=… / ?page=… เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (formatThaiDate withTime) ในบรรทัด meta, ใช้ <details> ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); generateMetadata ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
  • ไม่มี Server Action/SSE/migration ใหม่ (schema type_id + index มากับ N8 ใน migration เดียวกัน) — การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก / ทันที ไม่มี Router Cache ค้าง
  • Out of scope ของ N9: จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, export JSON ตามกลุ่ม (N7 ยังรับ fileId เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (N10 docs/PLAN-API.md)

N10 · API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอกด้วย API Key (2026-09-24)

แผนงานฉบับเต็ม: docs/PLAN-API.md — ผู้เรียกคือ backend ของ e-Court (server-to-server ไม่มี browser/UI) ต้องการดึง annotation/bookmark ที่เกิดในระบบเรากลับไปใช้ต่อ

  • Auth (lib/external-api-auth.ts): API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header x-api-key — เทียบด้วย SHA-256 ทั้งสองค่าแล้ว crypto.timingSafeEqual (hash ยาวเท่ากันเสมอ — กัน timing + length leak); env ไม่ตั้ง/ว่าง = fail closed 401 ทุก request ("API ยังไม่ได้ตั้งค่าคีย์"); ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน (กัน enumerate); ห้าม log ค่า key/header ดิบ — middleware.ts ยกเว้น /api/* อยู่แล้วจึงไม่ชนระบบ login oacCase เดิม; module นี้ไม่ import อะไรที่ลาก @/db มาด้วย (db/index.ts throw ตอน import ถ้าไม่มี DATABASE_URL — unit test ของ auth ต้องรันได้โดยไม่แตะ DB) และส่งออก externalApiError(message, status) สร้าง Response ผิดพลาด JSON {"error": "<ไทย>"} ใช้ร่วมทุก route ของ N10
  • Endpoint เดียว POST + JSON body: POST /api/external/documents (header x-api-key + content-type: application/json — เดิมชื่อ /api/external/query ถึงรอบสาม เปลี่ยนตาม resource ที่คืน 2026-09-25) — body { fileId และ/หรือ typeId, filter }: ถามเอกสารเดียวด้วย fileId ของ e-Court (ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ = 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)"; ไฟล์ url: fallback ของ N8 ค้นไม่ได้ = 404 ตามสัญญาลิงก์ที่แนะนำให้ส่ง fileId เสมอ) และ/หรือถามทุกเอกสารในกลุ่มด้วย typeId (N9 — กลุ่มว่าง = 200 documents: [] ต่างจากหน้า UI /groups/[typeId] ที่ 404) — ต้องมีอย่างน้อยหนึ่งอย่าง; ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข ไม่ตรงคู่ (ไม่เคยเปิด / คนละกลุ่ม) = 404 แยกสาเหตุในข้อความ (คำขอผู้ใช้ 2026-09-25 หลังทดสอบจริง · รอบเดียวกันเคยเป็น OR รวมผล · เดิมส่งพร้อมกัน = 400 กำกวม); รอบแรกเป็น 2 GET query-param แล้วรวมเป็นเส้นเดียวตามคำขอผู้ใช้ (2026-09-24 — ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด)
  • filter.type: "bookmarks" | "annotations" — ไม่ส่ง filter หรือ type เป็นค่าว่าง = ทั้งสอง (กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง — คำขอผู้ใช้ 2026-09-24), ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่); ค่าอื่น = 400 บอกค่าที่รับ — validation รวมทั้งรูปร่าง body ทั้งหมด (field นอก fileId/typeId/filter = 400 กัน typo, ชนิดไม่ใช่ string = 400, filter ผิดสัญญา = 400) อยู่ที่ parseQueryBody ใน lib/external-api.ts · filter.userId (string หรือ array — กรองตามเจ้าของรายการทั้ง annotations/bookmarks, replies ตาม annotation ที่ผ่านตัวกรองครบ; userId ไม่มีในระบบ = รายการว่างไม่ใช่ 400) + filter.annotationTypes (string หรือ array ค่า enum annotation_type ค่าอื่น = 400 — มีผลเฉพาะ annotations; เดิมชื่อ types เปลี่ยน 2026-09-25 กันสับสนกับ filter.type) เพิ่ม 2026-09-25 ตามคำขอผู้ใช้ ค่าว่าง = ไม่ได้กรองทุก field
  • Response รูปเดียวเสมอ: { documents: [ { id, externalFileId, title, typeId, createdAt, annotations: [AnnotationDTO + url+replies: ReplyDTO[]], bookmarks: [BookmarkDTO + url] } ] } — bundle ยุบชั้น: field ของเอกสารอยู่ระดับเดียวกับ annotations/bookmarks ไม่มี object document ซ้อน (คำขอผู้ใช้ 2026-09-25 · ถามด้วย fileId = ยาว 1 ชิ้น) · url ต่อรายการ = deep link F12 กลับมาหน้า viewer (annotation ?ann=<id> / bookmark ?page=<หน้า 1-based> — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) เป็น URL เต็ม: origin จาก request ของผู้เรียก (route อ่าน x-forwarded-proto/x-forwarded-host แล้ว fallback host ตรง ส่งเข้า parameter origin ของ loadDocumentBundles) + basePath ผ่าน withBasePath (เดิมเป็น relative เปลี่ยนตามคำขอผู้ใช้ 2026-09-25) — ลำดับ: documents ตาม files.createdAt (เหมือนหน้า /groups/[typeId] — ส่งทั้งคู่ = ยาว 1 ชิ้น) · annotations ตาม (pageIndex, createdAt) เหมือนหน้าสรุป F15/CSV · bookmarks ตาม N5 (pageIndex → yOffset → createdAt); timestamp ISO string ผ่าน mapper toDTO ทั้งหมด (invariant #5); typeId: null เมื่อไฟล์ไม่มีกลุ่ม และ externalFileId: null เมื่อเอกสารในกลุ่มไม่มี fileId ของ e-Court (ลงทะเบียนแบบ url: fallback หรืออัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId null)
  • ส่วนกลางของ route: lib/external-api.ts — parseQueryBody (คืน discriminated 2 กรณี: mode: "file" มี fileId + typeId อาจมาด้วยสำหรับ AND / mode: "type" typeId เดี่ยว) + loadDocumentBundles(fileRows, parts) (bulk — inArray ทุกไฟล์ในชุด query เดียว: annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory map เป็น DTO — ใช้ function เดียวกันทุกทาง (เอกสารเดียว = array ยาว 1) ให้รูปร่าง bundle ตรงกันเป๊ะ; route ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract (ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง)
  • ความปลอดภัย: read-only ทั้งหมด — ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath (snapshot ณ ตอนเรียก — คนละ context กับ Server Action ใน invariant #4); ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียน); REDACTION รวมใน annotations เมื่อเลือก include (ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง — DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง — key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น); annotation ทุกสถานะ (resolved ดูจาก field) ทุกผู้ใช้; route ทั้งสองเรียก requireExternalApiKey เป็นอย่างแรกก่อนแตะ DB
  • Out of scope ของ N10: หลาย key/ตาราง key ใน DB/UI จัดการ key, rate limiting/pagination/caching, write API (สร้าง/แก้ annotation ผ่าน API), ดึง bytes ไฟล์ผ่าน API นี้, webhook/event push, XML/CSV, auth จริงเทียบระบบ e-Court