# แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key) > สถานะ: **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` ## Context - ผู้เรียกคือ **backend ของ e-Court** (server-to-server — ไม่มี browser ไม่มีหน้า UI) ต้องการดึงข้อมูลที่เกิดในระบบเรากลับไปใช้ต่อ: annotation/bookmark ของเอกสารหนึ่งฉบับ หรือของทุกเอกสารในกลุ่ม (`typeId`) เดียวกัน - ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี - `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม ## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 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:"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา 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+ ตัวอักษร; เทียบกับค่า **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 — Endpoint เดียว (POST + JSON body) URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https:///docedit/note/api/external/documents` **`POST /api/external/documents`** — header `x-api-key` + `content-type: application/json`, body: ```jsonc { "fileId": "", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND) "typeId": "", // ถามทุกเอกสารในกลุ่ม N9 "filter": { "type": "bookmarks", // optional — "bookmarks" | "annotations", ไม่ส่ง = ทั้งสอง "userId": "", // optional — string หรือ array, กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks) "annotationTypes": ["COMMENT", "HIGHLIGHT"], // optional — ค่าจาก enum annotation_type, มีผลเฉพาะ annotations } } ``` Response 200 เป็นรูปเดียวเสมอ (bundle ยุบชั้น — ไม่มี object `document` ซ้อน, 2026-09-25): ```jsonc { "documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId) { "id": "", "externalFileId": "", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null) "title": "…", "typeId": "…", // null ได้ (ไม่มีกลุ่ม) "createdAt": "", "annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "url" + "replies": ReplyDTO[] ต่อรายการ ], "bookmarks": [ BookmarkDTO ตาม db/schema.ts พร้อม "url" ต่อรายการ ] } ] } ``` - **`url` ต่อรายการ = deep link กลับมาหน้า viewer ของเรา** (F12 — เพิ่ม 2026-09-25 ตามคำขอผู้ใช้): annotation → `…/annotate/?ann=` (ไปรายการ) · bookmark → `…/annotate/?page=<หน้า 1-based>` (ไปหน้า — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) — เป็น **URL เต็ม (absolute)** เช่น `https:///docedit/note/annotate/?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 (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 / สิทธิ์ / ความปลอดภัย - read-only ทั้งหมด: **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** — snapshot ณ ตอนเรียก (คนละ context กับ Server Action ใน invariant #4) - ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียก; ไม่เปิด `Access-Control-Allow-Origin`) - ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ) - **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น - annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO) - 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`, `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) | ## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) | # | งาน | ไฟล์หลัก | Verify เพิ่มเติม | |---|---|---|---| | 1 | Docs sync N10 | ตารางด้านบน | สเปก §7 ครบตาม format; README มี env + ตัวอย่าง | | 2 | Auth helper | `lib/external-api-auth.ts` | unit test (vitest): ไม่ใส่ header / key ผิด / env ว่าง = 401; key ถูก = ผ่าน — ไม่มีข้อความบอกว่าขาดอะไร | | 3 | Route เอกสารเดียว | `app/api/external/document/route.ts` | curl: 200 รูปร่างตาม D2 (replies ใน annotation, ISO timestamps), `include=bookmarks` → `annotations: []`, fileId ไม่รู้จัก = 404, `include=foo` = 400 | | 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:"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน 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:"` ของ 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 (ไม่ทำในแผนนี้) - หลาย key / ตาราง key ใน DB / เพิกถอนราย key / UI จัดการ key - rate limiting / pagination / caching ของ response - write API (สร้าง/แก้ annotation ผ่าน API) — ระบบยังเขียนผ่าน Server Actions + login เท่านั้น - ดึงไฟล์ PDF/bytes เอกสารผ่าน API นี้ (`/api/pdf` มีอยู่แล้วแต่ไม่มี key — ความเสี่ยงที่รับไว้ตามข้อ 3 ของ plan-reviewer ใน PLAN-ECOURT.md) - webhook/event push แจ้งเตือนเมื่อมี annotation ใหม่, API รูปแบบอื่น (XML/CSV) - auth จริงเทียบระบบ e-Court (ยัง out of scope ของโปรเจกต์)