external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-25 12:02:42 +07:00
parent df502088b7
commit f4d46c7720
20 changed files with 656 additions and 222 deletions

View file

@ -1,6 +1,6 @@
# แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key)
> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication"
> สถานะ: **implement แล้ว 2026-09-24** (รันผ่าน `/implement-feat N10`) — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication"
> Dependency: **N8** (`docs/PLAN-ECOURT.md` — `external_id` สำหรับค้นด้วย fileId ของ e-Court) + **N9** (`docs/PLAN-DOCSET.md` — `files.type_id` สำหรับค้นตามกลุ่ม)
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N10**) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N10`
@ -10,55 +10,66 @@
- ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี
- `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22)
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22 · ปรับรอบสอง 2026-09-24 · รอบสาม 2026-09-25 · รอบสี่ 2026-09-25 · รอบห้า 2026-09-25 · รอบหก 2026-09-25 · รอบเจ็ด 2026-09-25 · รอบแปด 2026-09-25 · รอบเก้า 2026-09-25)
1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key)
2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:<fileId>"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา
3. **2 endpoints ตาม pattern query-param ของบ้าน** (เหมือน `/api/annotations/export?fileId=`) ไม่ใช้ path parameter
4. **`include` เลือกส่วนที่เอา**: `annotations`, `bookmarks` คั่น comma — default = ทั้งสอง ("เอาทั้งหมด"), `include=bookmarks` = เฉพาะ bookmark — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค)
3. **POST เส้นเดียว + JSON body** `POST /api/external/documents` — รอบแรกเป็น 2 endpoints แบบ GET query-param (ตาม pattern ของบ้าน), **รอบสอง 2026-09-24 ตามคำขอผู้ใช้รวมเป็นเส้นเดียวถามด้วย body** `{fileId | typeId, filter}` — GET ที่แนบ body ไม่ standard จึงใช้ POST (ยัง read-only เหมือนเดิม) · response รูปเดียวเสมอ (`{documents: [...]}`) ผู้เรียกจัดการ contract เดียว · **รอบสาม 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกันได้ = รวมผล OR** (เดิม 400 กำกวม) และ**ยุบชั้น `document` ใน response** ให้ field เอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` (ดูง่ายขึ้น) · **รอบสี่ 2026-09-25 ตามคำขอผู้ใช้: เปลี่ยนชื่อ endpoint เป็น `documents`** (เดิม `query` — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}`) · **รอบห้า 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกัน = AND** เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (แทน OR รวมผลของรอบสาม — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ไม่ใช่สิ่งที่ผู้เรียกต้องการ) · ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด (ถอด GET เดิมทั้งสองออก)
4. **`filter.type` เลือกส่วนที่เอา**: `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง ("เอาทั้งหมด" — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง, คำขอผู้ใช้ 2026-09-24) — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) · **รอบหก 2026-09-25 เพิ่มมิติตัวกรอง**: `filter.userId` (กรองตามเจ้าของรายการ) + `filter.annotationTypes` (ชนิดของ annotation — เดิมชื่อ `types` เปลี่ยนรอบแปด) — ดู D2
## Design
### D1 — Auth (`lib/external-api-auth.ts` ใหม่)
- `requireExternalApiKey(req: Request): Response | null` — คืน `null` เมื่อผ่าน / คืน `Response` 401 เมื่อไม่ผ่าน (body JSON `{ "error": "<ข้อความไทย>" }` + `content-type: application/json; charset=utf-8`)
- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร)
- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร; เทียบกับค่า **trim แล้ว** — กัน trailing newline จาก K8s secret / docker env_file ทำ 401 ทั้งระบบทั้งที่ผู้เรียกส่ง key ถูก — code review 2026-09-24)
- **env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request** (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี
- ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed")
### D2 — Endpoints (2 routes ใหม่)
### D2 — Endpoint เดียว (POST + JSON body)
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https://<host>/docedit/note/api/external/...`
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https://<host>/docedit/note/api/external/documents`
**1) เอกสารเดียว** — `GET /api/external/document?fileId=<fileId ของ e-Court>&include=annotations,bookmarks`
**`POST /api/external/documents`** — header `x-api-key` + `content-type: application/json`, body:
```jsonc
{
"document": { // null = 404 (ไม่เคยเปิดในระบบ)
"id": "<uuid ของเรา>", // ใช้อ้างต่อใน DTO ข้างล่าง
"externalFileId": "<fileId ของ e-Court>",
"title": "…",
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
"createdAt": "<ISO string>"
},
"annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "replies": ReplyDTO[] ต่อรายการ ],
"bookmarks": [ BookmarkDTO ตาม db/schema.ts ]
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
"typeId": "<typeId>", // ถามทุกเอกสารในกลุ่ม N9
"filter": {
"type": "bookmarks", // optional — "bookmarks" | "annotations", ไม่ส่ง = ทั้งสอง
"userId": "<userId ของเรา>", // optional — string หรือ array, กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks)
"annotationTypes": ["COMMENT", "HIGHLIGHT"], // optional — ค่าจาก enum annotation_type, มีผลเฉพาะ annotations
}
}
```
**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=<typeId>&include=…`
Response 200 เป็นรูปเดียวเสมอ (bundle ยุบชั้น — ไม่มี object `document` ซ้อน, 2026-09-25):
```jsonc
{
"typeId": "…",
"documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1
"documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId)
{
"id": "<uuid ของเรา>",
"externalFileId": "<fileId ของ e-Court>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
"title": "…",
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
"createdAt": "<ISO string>",
"annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "url" + "replies": ReplyDTO[] ต่อรายการ ],
"bookmarks": [ BookmarkDTO ตาม db/schema.ts พร้อม "url" ต่อรายการ ]
}
]
}
```
- **`url` ต่อรายการ = deep link กลับมาหน้า viewer ของเรา** (F12 — เพิ่ม 2026-09-25 ตามคำขอผู้ใช้): annotation → `…/annotate/<fileId>?ann=<annotationId>` (ไปรายการ) · bookmark → `…/annotate/<fileId>?page=<หน้า 1-based>` (ไปหน้า — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) — เป็น **URL เต็ม (absolute)** เช่น `https://<host>/docedit/note/annotate/<fileId>?ann=…`: origin อ่านจาก request ที่เรียกเข้ามา (route เชื่อ header `x-forwarded-proto`/`x-forwarded-host` ของ reverse proxy ก่อน — หลายชั้นส่ง comma list ใช้ค่าแรก — แล้ว fallback host/protocol ของ request ตรง ๆ) + basePath ผ่าน `withBasePath` (เดิมเป็น relative path ให้ผู้เรียกเติม origin เอง — เปลี่ยนตามคำขอผู้ใช้ 2026-09-25)
- **body validation (strict กัน typo เงียบ ๆ):** ต้องเป็น JSON object · รับเฉพาะ 3 field `fileId`/`typeId`/`filter` (field อื่น = 400 บอกชื่อ field) · `fileId`/`typeId` ต้องเป็น string (ชนิดอื่น = 400; trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี `fileId` หรือ `typeId` **อย่างน้อยหนึ่งอย่าง** — ขาดทั้งคู่ = 400; **ส่งพร้อมกันได้ = AND** กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข เหลือยาว 1 ชิ้น (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิมส่งพร้อมกัน = 400 กำกวม — ยังไม่มีผู้เรียกจริงจึงเปลี่ยนกติกาได้ขาด) · `filter` ถ้าส่งต้องเป็น object ที่มีเฉพาะ field `type`/`userId`/`annotationTypes` (field อื่นใน filter = 400) — `type` ค่า `"bookmarks"`/`"annotations"` — ค่าว่าง (หลัง trim) หรือไม่มี = ไม่ได้กรอง เอาทั้งสอง ส่วนค่าอื่น = 400 บอกค่าที่รับ · **`userId`** string หรือ array ของ string (trim แล้วค่าว่างทั้งคู่ = ไม่ได้กรอง) กรองตาม**เจ้าของรายการ**ทั้ง annotations/bookmarks — ไม่ใช่ผู้ตอบ (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) · **`annotationTypes`** string หรือ array ค่าจาก enum `annotation_type` (`HIGHLIGHT`/`DRAW`/`COMMENT`/`SHAPE`/`REDACTION`/`TEXTBOX`/`STAMP` — ค่าอื่น = 400 บอกค่าที่รับกัน typo เงียบ) มีผลเฉพาะเมื่อรวม annotations (รอบหก 2026-09-25 · เดิมชื่อ field `types` เปลี่ยนรอบแปด)
- **ไม่มีเอกสารในกลุ่ม = 200 + `documents: []`** (ต่างจากหน้า UI `/groups/[typeId]` ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration)
- โค้ดกลางของ route: `lib/external-api.ts` — `parseQueryBody` (validate body ตามข้อบน คืน discriminated 2 กรณี: `mode: "file"` มี fileId + typeId อาจมาด้วยสำหรับ AND / `mode: "type"` typeId เดี่ยว) + `loadDocumentBundles(fileRows, parts)` (**bulk**: query รวมชุดเดียวด้วย `inArray` ต่อทุกไฟล์ — annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่มด้วย Promise.all ที่กด connection pool เมื่อกลุ่มใหญ่ — code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory (push ตามลำดับ query จึงรักษาลำดับเดิมในไฟล์เดียวกัน) map เป็น DTO — สอง mode ใช้ function เดียวกัน (เอกสารเดียว = array ยาว 1) ได้รูปร่าง bundle ตรงกันเป๊ะ; route กรองเงื่อนไขคู่ (`externalId` + `typeId`) ใน query เดียว และ**ครอบ try/catch ทุก DB call** — ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง
- ลำดับ: `documents` ตาม `files.createdAt` (เหมือนหน้า `/groups/[typeId]`) · `annotations` ตาม `(pageIndex, createdAt)` เหมือนหน้าสรุป F15 · `bookmarks` ตาม N5 (`pageIndex → yOffset → createdAt`)
- timestamp เป็น ISO string ทั้งหมด (ผ่าน mapper `toDTO` ที่มีอยู่ — invariant #5), `data` คือ JSONB ตาม discriminated union เดิม
- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (param ขาด/`include` ค่าไม่รู้จัก), 401 (key), 404 (เอกสารเดียว: fileId ไม่เคยลงทะเบียน)
- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (body ไม่ใช่ JSON / object ผิดสัญญา / เงื่อนไข fileId–typeId / `filter` ค่าไม่รู้จัก), 401 (key), 404 (ถามด้วย fileId ที่ไม่เคยลงทะเบียน), **500 (query ฐานข้อมูลล้ม — ข้อความกลาง ๆ ไม่แนบรายละเอียด กัน leak SQL/path, log stack ฝั่ง server อย่างเดียว · เพิ่ม code review 2026-09-25)**
### D3 — การ query / สิทธิ์ / ความปลอดภัย
@ -67,14 +78,14 @@ URL เต็มฝั่งผู้เรียกอยู่ใต้ basePa
- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น
- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO)
- route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
- route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
| ไฟล์ | สิ่งที่แก้ |
|---|---|
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N10 · API ดึงข้อมูลเอกสาร/กลุ่ม (API Key)** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-API.md`) |
| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `app/api/external/document/route.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) |
| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `lib/external-api.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) |
| `.claude/rules/data-layer.md` | เพิ่ม: routes ของ N10 เป็น read-only ไม่ publish SSE/`revalidatePath`; env `EXTERNAL_API_KEY` ว่าง = fail closed |
| `README.md` | สารบัญ env เพิ่ม `EXTERNAL_API_KEY` + ตัวอย่างการเรียก (curl) |
@ -88,18 +99,37 @@ URL เต็มฝั่งผู้เรียกอยู่ใต้ basePa
| 4 | Route กลุ่ม | `app/api/external/documents/route.ts` | curl: หลายไฟล์เรียง `createdAt`, typeId ไม่มี = `documents: []`, รูปร่าง document เหมือน endpoint 1 เป๊ะ |
| 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; ทดสอบร่วมกับ N8/N9 ด้วยข้อมูลจริงจากลิงก์ e-Court |
> **รอบสอง (2026-09-24 ตามคำขอผู้ใช้):** task 3+4 ถูกแทนที่ — รวม 2 GET เป็น **`POST /api/external/query` เส้นเดียว** (route `app/api/external/query/route.ts` + `parseQueryBody` ใน `lib/external-api.ts` แทน `parseInclude`; ถอด `app/api/external/document/` กับ `app/api/external/documents/` ออก — ยังไม่มีผู้เรียกจริง) ตาม D2 ฉบับใหม่
>
> **รอบสาม (2026-09-25 ตามคำขอผู้ใช้):** (1) รับ `fileId`+`typeId` **พร้อมกัน** = รวมผล OR dedupe เรียง `files.createdAt` (ถอดกติกาเดิม "ส่งพร้อมกัน = 400 กำกวม") (2) ยุบชั้น `document` ออกจาก response — field เอกสาร (`id`/`externalFileId`/`title`/`typeId`/`createdAt`) ขึ้นมาอยู่ระดับเดียวกับ `annotations`/`bookmarks` ให้อ่านง่ายขึ้น (3) เพิ่ม `url` deep link กลับมาหน้า `/annotate/[id]` ต่อ annotation/bookmark
>
> **รอบสี่ (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ endpoint `POST /api/external/query` → **`POST /api/external/documents`** (ย้าย route เป็น `app/api/external/documents/route.ts`) — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}` · ยังไม่มีผู้เรียกจริงจึงเปลี่ยนได้ขาด ไม่ต้องเก็บ path เดิม
>
> **รอบห้า (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยน semantics ของการส่ง `fileId`+`typeId` พร้อมกันจาก **OR รวมผล** (รอบสาม) เป็น **AND กรองเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข** — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ผู้เรียกต้องการเฉพาะเอกสารตามที่ส่งมา; ไม่ตรงคู่ (ไม่เคยเปิด / คนละกลุ่ม) = 404 แยกสาเหตุในข้อความ · ถอด Map dedupe + sort ที่จำเป็นแค่ตอน OR ออก (เหลือ query เงื่อนไขคู่ในคำสั่งเดียว)
>
> **รอบหก (2026-09-25 ตามคำขอผู้ใช้):** เพิ่มมิติตัวกรองใน `filter` — **`userId`** (string หรือ array — กรองตามเจ้าของรายการทั้ง annotations/bookmarks; replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง ไม่กรองตามผู้ตอบ) + **`types`** (ค่า enum `annotation_type` — มีผลเฉพาะ annotations) ทั้งคู่ค่าว่าง = ไม่ได้กรองตามกฎเดิม · `since` (ดึง incremental) ที่เสนอด้วยถูกตัด — นิยาม "สร้างหลังจาก X" ไม่ครอบคลุมรายการเก่าที่ถูกแก้ไขภายหลัง ถ้าจะมีวันนั้นต้องออกแบบเป็น change feed เต็มรูปแบบ
>
> **รอบเจ็ด (2026-09-25 ตาม code review ของผู้ใช้):** (1) route **ครอบ try/catch ทุก DB call** — ก่อนหน้า query ล้มจะหลุดเป็น unhandled exception กลายเป็น 500 ของ framework ที่ format ต่างจาก JSON error contract ของ endpoint นี้ทั้งหมด (2) **ถอด fan-out ต่อไฟล์** — `loadDocumentBundle` ต่อไฟล์ + `Promise.all` ทั้งกลุ่ม (3 query ต่อไฟล์พร้อมกัน กด connection pool เมื่อกลุ่มใหญ่) เปลี่ยนเป็น `loadDocumentBundles` bulk: `inArray` ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ (ดีกว่า batching เป็นกลุ่มเล็กที่ยังยิง N รอบ)
>
> **รอบแปด (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ field `filter.types` → **`filter.annotationTypes`** — ชื่อเดิมสั้นจนชนกับ `filter.type` (การเลือก bookmarks/annotations) อ่านสับสน; ยังไม่มีผู้เรียกจริงจึงเปลี่ยนขาด ไม่ต้องรับชื่อเดิม
>
> **รอบเก้า (2026-09-25 ตามคำขอผู้ใช้):** `url` เปลี่ยนจาก relative path เป็น **URL เต็ม** — route อ่าน origin จาก request ของผู้เรียก (`x-forwarded-proto`/`x-forwarded-host` ก่อน แล้ว fallback host/protocol ของ request) ส่งเข้า `loadDocumentBundles` เป็น parameter `origin` — ผู้เรียกใช้ลิงก์ได้ทันทีไม่ต้องประกอบเอง; ข้อควรระวัง: deploy ผ่าน reverse proxy ต้องส่ง header forward ให้ถูกต้อง ไม่งั้น origin จะเป็นของ proxy แทนที่จะเป็น public URL
## Edge cases (ออกแบบไว้ล่วงหน้า)
1. **env `EXTERNAL_API_KEY` ไม่ตั้ง/ว่าง** → 401 ทุก request (fail closed) — deploy ต้องตั้งก่อนเปิดใช้
2. **key ผิด** → 401 ข้อความเดียวกับไม่ใส่ key (ไม่บอกว่าขาดอะไร — กัน enumerate)
3. **fileId ที่ e-Court ไม่เคยลิงก์มา** → 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)" — ไม่ใช่ 500
4. **ไฟล์ลงทะเบียนแบบ `"url:<href>"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน
5. **`include` ซ้ำค่า / มี whitespace / ค่าไม่รู้จัก** → trim + dedupe; มีค่านอก `{annotations, bookmarks}` สักตัว = 400 บอกค่าที่รับ
6. **ไม่มี param `fileId`/`typeId` เลย** → 400 ข้อความไทยระบุ param ที่ขาด
7. **`typeId` ตัวเปล่า / `include` ตัวเปล่า** → 400 (ไม่ปล่อยผ่านเป็น "ทั้งหมด")
5. **`filter.type` ค่าไม่รู้จัก** (รวมเอกพจน์ "annotation"/"bookmark") → 400 บอกค่าที่รับ · **`type` ค่าว่าง/whitespace ล้วน หรือไม่มี (`filter: {}`)** → ไม่ได้กรอง เอาทั้งสอง (กฎเดียวกับ fileId ว่าง — คำขอผู้ใช้ 2026-09-24 ตอนทดลองยิง `"type": ""` เจอ 400) — เดิมรอบแรกเป็น query-param `include` แบบ comma (trim + dedupe) เปลี่ยนมาเป็น `filter.type` ตามรอบสอง
6. **ไม่มีทั้ง `fileId` และ `typeId`** (ไม่ส่งเลย หรือส่งมาแต่ค่าว่าง/whitespace ล้วนจนไม่เหลือ) → 400 ข้อความไทย (ต้องระบุอย่างน้อยหนึ่งอย่าง)
7. **ส่งทั้ง `fileId` และ `typeId` พร้อมกัน** → AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิม = 400 กำกวม) · **ไม่ตรงคู่ = 404** — ไฟล์ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ `typeId` ที่ระบุ (แยกสาเหตุในข้อความ) · **field นอกเหนือ `fileId`/`typeId`/`filter`** → 400 บอกชื่อ field (กัน typo เช่น `fileID` เงียบ ๆ) · **`filter` ผิดสัญญา** (ไม่ใช่ object / มี field อื่นนอกจาก `type` / ไม่มี `type`) → 400
8. **เอกสารไม่มี annotation/bookmark เลย** → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
9. **ไฟล์ไม่มีกลุ่ม** → `document.typeId: null` (ไม่ใช่ string ว่าง)
10. **คนละรอบเรียกพร้อมกัน** → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool `prepare: false` ตาม invariant #11)
11. **เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court** (ลงทะเบียนแบบ `"url:<href>"` ของ N8 หรืออัปโหลดผ่าน N4 พร้อมช่องกลุ่ม) → endpoint กลุ่มยังรวมไฟล์เหล่านี้ แต่ `document.externalFileId: null` — mirror กฎ `typeId: null` ของข้อ 9 (endpoint เดียวไม่กระทบ — ค้นด้วย `ecourt:` prefix เจอเฉพาะไฟล์ที่มี fileId เสมอ)
12. **`filter.userId` ที่ไม่มีในระบบ** → ไม่ใช่ 400 — ได้ `annotations: []`/`bookmarks: []` ตาม contract (ตัวเอกสารยังคืนถ้าเคยเปิด) เพราะไม่ validate กับ DB (ผู้ใช้เพิ่ม/เปลี่ยนได้ตลอด ตรวจก็เสีย query เปล่า) · **`filter.annotationTypes` เมื่อเลือก `type: "bookmarks"` อย่างเดียว** → เพิกเฉยเงียบ ๆ ไม่ใช่ 400 (ไม่มี annotation ให้กรองอยู่แล้ว — ค่า annotationTypes ยังถูก validate ตามปกติก่อนถึงขั้นนั้น)
13. **query ฐานข้อมูลล้มกลางคัน** (connection หลุด / timeout / table มีปัญหา) → **500 JSON ตาม contract** ผ่าน `externalApiError` ไม่ใช่ 500 ของ framework ที่ format ต่าง — try/catch ที่ route ครอบทุก DB call (code review 2026-09-25) · log stack ฝั่ง server อย่างเดียว ข้อความตอบเป็นกลาง ๆ ไม่แนบ error ดิบ · ไม่ retry ฝั่งเรา (read-only snapshot — ผู้เรียกตัดสินใจเรียกซ้ำเอง)
## Out of scope (ไม่ทำในแผนนี้)

View file

@ -1,6 +1,8 @@
# แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)
> **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด)
>
> **อัปเดต 2026-09-25:** N7 ถูก**ถอดออกจากโค้ดทั้งหมด**ตามคำขอผู้ใช้ (route + ปุ่มใน sidebar/หน้าสรุป) — ระบบภายนอกดึงข้อมูล bookmark ผ่าน **N10 API** (`POST /api/external/documents`, แผน `docs/PLAN-API.md`) แทน; ทั้งแผนนี้และ §7 N7 ของ PLAN-FEAT.md เป็น historical record ทั้งคู่แล้ว
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก
> อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅)

View file

@ -196,7 +196,7 @@ Index: `(file_id)`
- **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 ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป
- **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)
@ -332,7 +332,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
### 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
> แผนงานฉบับเต็ม (ตอนเป็น 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 ที่ตั้งไว้)
@ -363,3 +363,15 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
- **หน้า `/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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

View file

@ -2,7 +2,7 @@
- **วันที่ตรวจสอบ:** 2026-09-14
- **อัปเดตล่าสุด:** 2026-09-17 — ปิด gap 4 ข้อ: 5.3.11 ✅ (N5), 5.3.12 ✅ (N6), 5.3.14 ✅ (API ส่งออก bookmark เป็น JSON), 5.3.15 ✅ (export PDF ของ F8 มี bookmark ติดไป) · 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่
- **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System)
- **เอกสาร requirement ต้นทาง:** ~~`docs/requirement/1.png`, `docs/requirement/2.png`~~ (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System — รูปทั้งสองถูกถอดออกจาก repo 2026-09-25 ตามคำขอผู้ใช้ ดูต่อได้จาก git history ก่อนหน้านี้)
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` — ข้อ 5.3.11–5.3.15 อัปเดตตามโค้ดล่าสุด 2026-09-17 (รวม N5–N6 commit `a020e96` และ N7 → JSON export) · ตรวจครั้งแรก ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
- **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25**
@ -48,7 +48,7 @@
| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ✅ | ทำแล้ว (2026-09-16, N5): แท็บ "ที่คั่นหน้า" เรียงตาม **หน้า → ตำแหน่งบนหน้า (yOffset) → เวลาสร้าง** ทั้ง root ของต้นไม้และลูกในแม่เดียวกัน — ลำดับเดียวกันใช้ทุกที่: PDF outline ตอน export (F8), ผลค้นหา (N6), ส่งออก JSON (N7) |
| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ✅ | ทำแล้ว (2026-09-16, N6): block ค้นหาเฉพาะแท็บ "ที่คั่นหน้า" — คำค้น (ชื่อ+โน้ต) + เงื่อนไข **ช่วงหน้า / ผู้บันทึก / ช่วงวันที่บันทึก** ผสมกันได้ + ปุ่มล้างเงื่อนไข (Server Action `searchBookmarks` — แผน `docs/PLAN-BOOKMARK.md`) |
| **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ทำแล้ว (2026-09-17): GET `/api/bookmarks/export?fileId=` คืน **JSON** ข้อมูล bookmark ทั้งไฟล์ (envelope `fileId`/`title`/`exportedAt` + `BookmarkDTO[]` ครบทุก field รวม `parentId` โครงสร้างต้นไม้ เรียงตามลำดับหน้า) — ระบบอื่นดึงข้อมูลไปใช้ต่อได้ทันที (read-only, ยังไม่มี auth ตามขอบเขตเฟสแรก) |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ปัจจุบันตอบด้วย **N10 API** (2026-09-25): `POST /api/external/documents` (auth API Key `x-api-key`) คืน bookmark พร้อม annotation ทั้งเอกสาร/ทั้งกลุ่ม พร้อม filter ผู้บันทึก/ชนิด — เดิมเคยเป็น GET `/api/bookmarks/export?fileId=` (2026-09-17) แล้ว**ถูกถอด 2026-09-25** ตามคำขอผู้ใช้ เพราะ N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth |
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ✅ | export PDF ของ F8 (`app/api/annotations/export/route.ts`) ฝัง bookmark เป็น **PDF outline** ติดไปกับไฟล์ (ต้นไม้ 3 ระดับ เรียงตามหน้า) — เจ้าของเอกสารยืนยัน (2026-09-17) ถือว่าตอบข้อนี้แล้ว · หมายเหตุ: รายงาน PDF แยกต่างหากของ N7 เคยทำแล้วถูกเปลี่ยนเป็น**ส่งออก JSON** ตามคำขอผู้ใช้ (2026-09-17) |
| **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed |
| **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว |
@ -70,7 +70,7 @@
- Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23)
- Private/Group visibility ของ bookmark (5.3.9)
- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว (2026-09-17)**: GET `/api/bookmarks/export` คืน JSON ข้อมูล bookmark ทั้งไฟล์ ระบบอื่นดึงไปใช้ได้ (ดูตาราง 5.3.14)
- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว**: ปัจจุบันตอบด้วย **N10 API** `POST /api/external/documents` (API Key) ครอบทั้ง bookmark+annotation+replies — เดิม GET `/api/bookmarks/export` (2026-09-17) ถูกถอด 2026-09-25 (ดูตาราง 5.3.14)
- โหมด Integrate แบบ modal/iframe (5.3.16)
- เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25)