377 lines
87 KiB
Markdown
377 lines
87 KiB
Markdown
# 📋 Implementation Plan — Features ต่อยอด (Judge's Note)
|
||
|
||
> แผนงาน implement ฟีเจอร์ต่อยอดจากเฟสแรกที่ implement ครบแล้วตาม [`docs/PLAN.md`](./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 preset (ข้อความสำเร็จรูป); ลายเซ็น = ใช้ 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`)
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
| { 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
|
||
|
||
### F12 · C4 Deep Link
|
||
|
||
- 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)
|
||
|
||
---
|
||
|
||
## 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
|