112 lines
13 KiB
Markdown
112 lines
13 KiB
Markdown
|
|
# แผน 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)
|
|||
|
|
|
|||
|
|
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 คงที่ ง่ายต่อผู้บริโภค)
|
|||
|
|
|
|||
|
|
## 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+ ตัวอักษร)
|
|||
|
|
- **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`
|
|||
|
|
|
|||
|
|
```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 ]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=<typeId>&include=…`
|
|||
|
|
|
|||
|
|
```jsonc
|
|||
|
|
{
|
|||
|
|
"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 (ออกแบบไว้ล่วงหน้า)
|
|||
|
|
|
|||
|
|
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 (ไม่ปล่อยผ่านเป็น "ทั้งหมด")
|
|||
|
|
8. **เอกสารไม่มี annotation/bookmark เลย** → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
|
|||
|
|
9. **ไฟล์ไม่มีกลุ่ม** → `document.typeId: null` (ไม่ใช่ string ว่าง)
|
|||
|
|
10. **คนละรอบเรียกพร้อมกัน** → 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 ของโปรเจกต์)
|