eCourt-note/docs/PLAN-API.md
Your Name f4d46c7720 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>
2026-09-25 12:02:42 +07:00

32 KiB
Raw Blame History

แผน 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:<fileId>") — ผู้เรียกรู้จัก 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://<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 เชื่อ 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:<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 เส้นเดียว (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. 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 (ไม่ทำในแผนนี้)

  • หลาย 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 ของโปรเจกต์)