32 KiB
แผน 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)
- API Key เดียวใน env
EXTERNAL_API_KEY— ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key) - ระบุเอกสารด้วย fileId ของ e-Court (ค้น
external_id = "ecourt:<fileId>") — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา - 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 เดิมทั้งสองออก) 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เมื่อผ่าน / คืนResponse401 เมื่อไม่ผ่าน (body JSON{ "error": "<ข้อความไทย>" }+content-type: application/json; charset=utf-8)- อ่าน header
x-api-key→ เทียบกับ envEXTERNAL_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://<host>/docedit/note/api/external/documents
POST /api/external/documents — header x-api-key + content-type: application/json, body:
{
"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
}
}
Response 200 เป็นรูปเดียวเสมอ (bundle ยุบชั้น — ไม่มี object document ซ้อน, 2026-09-25):
{
"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 เชื่อ headerx-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 ที่มีเฉพาะ fieldtype/userId/annotationTypes(field อื่นใน filter = 400) —typeค่า"bookmarks"/"annotations"— ค่าว่าง (หลัง trim) หรือไม่มี = ไม่ได้กรอง เอาทั้งสอง ส่วนค่าอื่น = 400 บอกค่าที่รับ ·userIdstring หรือ array ของ string (trim แล้วค่าว่างทั้งคู่ = ไม่ได้กรอง) กรองตามเจ้าของรายการทั้ง annotations/bookmarks — ไม่ใช่ผู้ตอบ (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) ·annotationTypesstring หรือ array ค่าจาก enumannotation_type(HIGHLIGHT/DRAW/COMMENT/SHAPE/REDACTION/TEXTBOX/STAMP— ค่าอื่น = 400 บอกค่าที่รับกัน typo เงียบ) มีผลเฉพาะเมื่อรวม annotations (รอบหก 2026-09-25 · เดิมชื่อ fieldtypesเปลี่ยนรอบแปด) -
ไม่มีเอกสารในกลุ่ม = 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:<href>"(ลิงก์ไม่มี 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เส้นเดียว (routeapp/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) เพิ่มurldeep 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(ค่า enumannotation_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 เมื่อกลุ่มใหญ่) เปลี่ยนเป็นloadDocumentBundlesbulk: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เป็น parameterorigin— ผู้เรียกใช้ลิงก์ได้ทันทีไม่ต้องประกอบเอง; ข้อควรระวัง: deploy ผ่าน reverse proxy ต้องส่ง header forward ให้ถูกต้อง ไม่งั้น origin จะเป็นของ proxy แทนที่จะเป็น public URL
Edge cases (ออกแบบไว้ล่วงหน้า)
- env
EXTERNAL_API_KEYไม่ตั้ง/ว่าง → 401 ทุก request (fail closed) — deploy ต้องตั้งก่อนเปิดใช้ - key ผิด → 401 ข้อความเดียวกับไม่ใส่ key (ไม่บอกว่าขาดอะไร — กัน enumerate)
- fileId ที่ e-Court ไม่เคยลิงก์มา → 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)" — ไม่ใช่ 500
- ไฟล์ลงทะเบียนแบบ
"url:<href>"(ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน filter.typeค่าไม่รู้จัก (รวมเอกพจน์ "annotation"/"bookmark") → 400 บอกค่าที่รับ ·typeค่าว่าง/whitespace ล้วน หรือไม่มี (filter: {}) → ไม่ได้กรอง เอาทั้งสอง (กฎเดียวกับ fileId ว่าง — คำขอผู้ใช้ 2026-09-24 ตอนทดลองยิง"type": ""เจอ 400) — เดิมรอบแรกเป็น query-paramincludeแบบ comma (trim + dedupe) เปลี่ยนมาเป็นfilter.typeตามรอบสอง- ไม่มีทั้ง
fileIdและtypeId(ไม่ส่งเลย หรือส่งมาแต่ค่าว่าง/whitespace ล้วนจนไม่เหลือ) → 400 ข้อความไทย (ต้องระบุอย่างน้อยหนึ่งอย่าง) - ส่งทั้ง
fileIdและtypeIdพร้อมกัน → AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิม = 400 กำกวม) · ไม่ตรงคู่ = 404 — ไฟล์ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับtypeIdที่ระบุ (แยกสาเหตุในข้อความ) · field นอกเหนือfileId/typeId/filter→ 400 บอกชื่อ field (กัน typo เช่นfileIDเงียบ ๆ) ·filterผิดสัญญา (ไม่ใช่ object / มี field อื่นนอกจากtype/ ไม่มีtype) → 400 - เอกสารไม่มี annotation/bookmark เลย → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
- ไฟล์ไม่มีกลุ่ม →
document.typeId: null(ไม่ใช่ string ว่าง) - คนละรอบเรียกพร้อมกัน → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool
prepare: falseตาม invariant #11) - เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (ลงทะเบียนแบบ
"url:<href>"ของ N8 หรืออัปโหลดผ่าน N4 พร้อมช่องกลุ่ม) → endpoint กลุ่มยังรวมไฟล์เหล่านี้ แต่document.externalFileId: null— mirror กฎtypeId: nullของข้อ 9 (endpoint เดียวไม่กระทบ — ค้นด้วยecourt:prefix เจอเฉพาะไฟล์ที่มี fileId เสมอ) filter.userIdที่ไม่มีในระบบ → ไม่ใช่ 400 — ได้annotations: []/bookmarks: []ตาม contract (ตัวเอกสารยังคืนถ้าเคยเปิด) เพราะไม่ validate กับ DB (ผู้ใช้เพิ่ม/เปลี่ยนได้ตลอด ตรวจก็เสีย query เปล่า) ·filter.annotationTypesเมื่อเลือกtype: "bookmarks"อย่างเดียว → เพิกเฉยเงียบ ๆ ไม่ใช่ 400 (ไม่มี annotation ให้กรองอยู่แล้ว — ค่า annotationTypes ยังถูก validate ตามปกติก่อนถึงขั้นนั้น)- 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 ของโปรเจกต์)