13 KiB
แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key)
สถานะ: ร่างแผน (ยังไม่ implement) — เสนอ 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)
- 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 ของเรา - 2 endpoints ตาม pattern query-param ของบ้าน (เหมือน
/api/annotations/export?fileId=) ไม่ใช้ path parameter includeเลือกส่วนที่เอา:annotations,bookmarksคั่น comma — default = ทั้งสอง ("เอาทั้งหมด"),include=bookmarks= เฉพาะ bookmark — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค)
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+ ตัวอักษร) - env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี
- ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed")
D2 — Endpoints (2 routes ใหม่)
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: https://<host>/docedit/note/api/external/...
1) เอกสารเดียว — GET /api/external/document?fileId=<fileId ของ e-Court>&include=annotations,bookmarks
{
"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 ]
}
2) ทุกเอกสารในกลุ่ม — GET /api/external/documents?typeId=<typeId>&include=…
{
"typeId": "…",
"documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1
}
- ไม่มีเอกสารในกลุ่ม = 200 +
documents: [](ต่างจากหน้า UI/groups/[typeId]ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration) - ลำดับ:
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 ไม่เคยลงทะเบียน)
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, app/api/external/document/route.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 |
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 ก่อน includeซ้ำค่า / มี whitespace / ค่าไม่รู้จัก → trim + dedupe; มีค่านอก{annotations, bookmarks}สักตัว = 400 บอกค่าที่รับ- ไม่มี param
fileId/typeIdเลย → 400 ข้อความไทยระบุ param ที่ขาด typeIdตัวเปล่า /includeตัวเปล่า → 400 (ไม่ปล่อยผ่านเป็น "ทั้งหมด")- เอกสารไม่มี annotation/bookmark เลย → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
- ไฟล์ไม่มีกลุ่ม →
document.typeId: null(ไม่ใช่ string ว่าง) - คนละรอบเรียกพร้อมกัน → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool
prepare: falseตาม invariant #11)
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 ของโปรเจกต์)