เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
23 KiB
คู่มือการใช้ Documents API (N10)
สำหรับ: ทีม backend ของ e-Court ที่จะเรียก API นี้แบบ server-to-server (ไม่มี browser/หน้า UI) ใช้กับโค้ดเวอร์ชัน 2026-09-25 · สเปกฉบับเต็ม/เหตุผลการออกแบบ:
docs/PLAN-API.md
1. API นี้ทำอะไรให้
ดึงข้อมูลที่เกิดในระบบ Judge's Note กลับไปใช้ต่อ โดยถามได้ 2 แบบจาก endpoint เดียว:
| ต้องการ | ส่งอะไร | ได้อะไร |
|---|---|---|
| เอกสารเดียว | fileId (id ของ e-Court) |
documents ยาว 1 ชิ้น พร้อม annotation + bookmark ของไฟล์นั้น |
| ทุกเอกสารในกลุ่ม | typeId |
documents ทุกไฟล์ในกลุ่ม เรียงตามวันที่เปิดเข้าระบบ |
| เอกสารเดียว (ยืนยันกลุ่มด้วย) | fileId + typeId พร้อมกัน |
เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (AND) — ไม่ตรงคู่ = 404 |
เป็น read-only ทั้งหมด — ข้อมูลที่ได้เป็น snapshot ณ ตอนเรียก ไม่มี webhook/event push แจ้งเมื่อมีรายการใหม่ (ต้องยิงซ้ำเองตามความจำเป็น)
2. สิ่งที่ต้องมีก่อนเรียก
| สิ่งที่ต้องมี | ค่า |
|---|---|
| URL | https://<host>/docedit/api/note/external/documents (API อยู่ใต้ app/api/note/** ของ basePath /docedit — ต่างจากหน้า UI ที่อยู่ใต้ /docedit/note/…) |
| Method | POST (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
| Content-Type | application/json |
3. Authentication
ส่ง API Key ผ่าน header x-api-key ทุก request:
POST /docedit/api/note/external/documents HTTP/1.1
Host: <host>
x-api-key: <API Key ที่ได้รับ>
content-type: application/json
- ไม่ใส่ header หรือ key ผิด → 401 พร้อมข้อความเดียวกันทั้งสองกรณี (
{"error":"API key ไม่ถูกต้อง"}) - Key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น — ห้ามส่งต่อให้ browser/แอปฝั่งผู้ใช้ (API ไม่ได้ใส่ CORS headers เพราะออกแบบให้เรียกจาก server เท่านั้น)
- ผลลัพธ์รวมตำแหน่งกล่องปิดพราง (REDACTION) ด้วย — กล่าวคือผู้ถือ key เห็นว่ามีการปิดพรางตรงไหนของหน้า (เห็นเฉพาะพิกัด เนื้อหาใต้กล่องไม่ได้)
4. Request body
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว
"typeId": "<typeId ของกลุ่ม>", // ถามทุกเอกสารในกลุ่ม
"filter": { // optional ทั้งก้อน
"type": "bookmarks", // "bookmarks" | "annotations" — ไม่ส่ง = ทั้งสอง
"userId": "<userId ของระบบนี้>", // หรือ array — กรองตามเจ้าของรายการ
"annotationTypes": ["COMMENT", "HIGHLIGHT"] // หรือค่าเดียว — กรองชนิดของ annotation
}
}
4.1 field ระดับบนสุด
| field | ชนิด | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
fileId |
string | — | id ของเอกสาร ตามที่ e-Court ส่งมาในลิงก์ (?fileId=) ไม่ใช่ UUID ของระบบนี้ |
typeId |
string | — | รหัสกลุ่มเอกสาร ตามที่ส่งมาในลิงก์ (?typeId=) |
filter |
object | ไม่กรอง | ดู §4.2 |
กติกา:
- ต้องมี
fileIdหรือtypeIdอย่างน้อยหนึ่งอย่าง — ขาดทั้งคู่ = 400 - ส่งทั้งคู่ได้ = AND กรองเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (เช่น กันเข้าผิดกลุ่ม) — ไม่ตรงคู่ = 404
- ค่า string จะถูก trim — ส่งมาเป็นค่าว่าง/whitespace ล้วน = ถือว่าไม่ได้ส่ง field นั้น
- รับเฉพาะ 3 field นี้ — field อื่น (พิมพ์ผิด เช่น
fileID,filters) = 400 บอกชื่อ field ที่ไม่รู้จัก
4.2 filter
| field | ชนิด | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
type |
"bookmarks" | "annotations" |
ทั้งสอง | เลือกเอาเฉพาะส่วน — ส่วนที่ไม่เลือกคืนเป็น array ว่าง ไม่ใช่หายไปจาก response |
userId |
string หรือ array ของ string | ทุกคน | กรองตามเจ้าของรายการทั้ง annotations และ bookmarks (userId ระบบนี้ = u_userid ที่เห็นใน DTO) — ไม่กรองตามผู้ตอบ reply (reply ตามมาครบกับ annotation ที่ผ่านตัวกรอง) |
annotationTypes |
string หรือ array ของ enum | ทุกชนิด | กรองชนิดของ annotation — มีผลเฉพาะเมื่อผลลัพธ์รวม annotations (เลือก type:"bookmarks" อย่างเดียว = เพิกเฉยเงียบ ๆ ไม่ใช่ 400) |
ค่า enum ของ annotationTypes: HIGHLIGHT DRAW COMMENT SHAPE REDACTION TEXTBOX STAMP — ค่าอื่น = 400
filter รับเฉพาะ 3 field นี้เช่นกัน (type/userId/annotationTypes) — field อื่น = 400
5. Response 200
รูปเดียวเสมอ (ไม่ว่าถามแบบไหน): { "documents": [ … ] } — documents เป็น array ยาว 1 ตอนถามด้วย fileId, หลายชิ้นตอนถามด้วย typeId (กลุ่มว่าง = array ว่าง ไม่ใช่ error)
5.1 field ของแต่ละเอกสาร
| field | ชนิด | คำอธิบาย |
|---|---|---|
id |
string | UUID ของไฟล์ในระบบนี้ (ใช้ประกอบ url — ไม่ต้องจำ) |
externalFileId |
string | null | fileId ของ e-Court ตามที่ส่งมาในลิงก์ — null ได้: ไฟล์ในกลุ่มที่มาโดยไม่ผ่าน fileId (เข้าจากลิงก์ไม่มี fileId หรืออัปโหลดในระบบนี้) |
title |
string | ชื่อเอกสาร |
typeId |
string | null | กลุ่ม — null = ไม่มีกลุ่ม |
createdAt |
string | ISO 8601 (วันที่เปิดเข้าระบบครั้งแรก) |
annotations |
array | ดู §5.2 — เรียง (pageIndex, createdAt) |
bookmarks |
array | ดู §5.3 — เรียง pageIndex → yOffset → createdAt |
ลำดับ documents เมื่อถามด้วย typeId: ตาม createdAt ของไฟล์ (เก่า → ใหม่)
5.2 annotation แต่ละรายการ
| field | ชนิด | คำอธิบาย |
|---|---|---|
id |
string | UUID ของรายการ |
fileId |
string | UUID ของไฟล์ |
pageIndex |
number | 0-based — หน้า 1 ของเอกสาร = 0 |
userId / userName |
string | ผู้สร้างรายการ (userName = u_userlogin ของ e-Court) |
type |
string | หนึ่งใน 7 enum ด้านบน |
data |
object | รายละเอียดตามชนิด — ดูตาราง §5.4 |
createdAt / updatedAt |
string | ISO 8601 |
resolved |
boolean | สถานะ "จัดการแล้ว" (F10) |
resolvedAt |
string | null | ISO 8601 / null |
url |
string | deep link กลับมาหน้าอ่านเอกสารของระบบนี้ ไปตรงรายการนี้ (…/annotate/<id>?ann=<annotationId>) — เป็น URL เต็ม ใช้ได้ทันที |
replies |
array | คำตอบในเธรดของ comment — ดู §5.5 — เรียง createdAt |
รวม annotation ทุกสถานะ (ดู resolved) และทุกผู้ใช้ (เว้นแต่กรองด้วย filter.userId)
5.3 bookmark แต่ละรายการ
| field | ชนิด | คำอธิบาย |
|---|---|---|
id |
string | UUID ของรายการ |
fileId |
string | UUID ของไฟล์ |
pageIndex |
number | 0-based เช่นเดียวกับ annotation |
userId / userName |
string | ผู้สร้าง (userName อาจเป็น "" สำหรับรายการเก่า = ไม่ทราบชื่อ) |
title |
string | ชื่อหัวข้อ bookmark |
parentId |
string | null | โครงสร้าง 3 ระดับ — null = ระดับบนสุด, มีค่า = เป็นหัวข้อย่อยของ bookmark นั้น |
note |
string | null | โน้ตย่อยของ bookmark |
yOffset |
number | ตำแหน่งบนหน้า เป็น % ของความสูงหน้า (0–100) |
createdAt |
string | ISO 8601 |
url |
string | deep link กลับมาหน้าอ่าน (…/annotate/<id>?page=<หน้า> เป็น 1-based — ?page=1 = pageIndex 0) |
5.4 field data ของ annotation ตามชนิด
พิกัดทุกค่าเป็น % ของขนาดหน้า (ไม่ใช่ pixel — วาดทับตำแหน่งเดิมได้ทุกขนาดจอ): Rect = {x, y, width, height}, Point = {x, y}
type |
field ใน data |
หมายเหตุ |
|---|---|---|
HIGHLIGHT |
text, color, rects[], style?, opacity?, note? |
text = ข้อความที่คลุม, rects = 1 rect ต่อบรรทัด, style = highlight|underline|strikethrough|squiggle (ไม่มี = ไฮไลต์ทึบ) |
DRAW |
strokeColor, strokeWidth, paths[], pen?, widths?, opacity?, note? |
เส้นวาดมือ — strokeWidth เป็น % ของความกว้างหน้า, pen = pencil|highlighter, widths = ความหนาต่อจุดจากสไตลัส (ยาวเท่า paths) |
COMMENT |
position, message |
sticky note — position = ตำแหน่ง pin |
SHAPE |
shape, strokeColor, strokeWidth, note? + rect (rect/ellipse) หรือ from/to (line/arrow) |
shape = rect|ellipse|line|arrow |
REDACTION |
rect, note? |
กล่องปิดพราง — เห็นเฉพาะตำแหน่ง ไม่มีเนื้อหาใต้กล่องใน DB |
TEXTBOX |
position, text, color, fontSize, bold?, opacity?, align?, segments?, width?, note? |
position = มุมซ้ายบนของกล่อง, fontSize = % ของความกว้างหน้า, text เก็บข้อความรวมเสมอ (แม้มี segments ตัวหนาเฉพาะส่วน) |
STAMP |
label, position, scale, color?, note? |
ตรายางข้อความ preset — scale = ความกว้างกรอบ % ของความกว้างหน้า |
note (optional ทุกชนิด) = โน้ตคำอธิบายของรายการ เช่น เหตุผลที่ไฮไลต์
5.5 reply แต่ละรายการ (ใน replies ของ annotation)
| field | ชนิด | คำอธิบาย |
|---|---|---|
id |
string | UUID ของคำตอบ |
annotationId |
string | UUID ของ annotation แม่ |
userId / userName |
string | ผู้ตอบ |
message |
string | ข้อความ |
createdAt |
string | ISO 8601 |
6. Error
error ทุกสถานะเป็น JSON รูปเดียวกัน: { "error": "<ข้อความไทย>" }
| สถานะ | เมื่อไร | ข้อความ |
|---|---|---|
| 400 | body ไม่ใช่ JSON ที่ถูกต้อง | body ต้องเป็น JSON ที่ถูกต้อง |
| 400 | body ไม่ใช่ object | body ต้องเป็น JSON object |
| 400 | มี field ที่ไม่รู้จัก | ไม่รู้จัก field '<field>' (รับเฉพาะ fileId, typeId, filter) |
| 400 | fileId/typeId ไม่ใช่ string |
fileId ต้องเป็น string / typeId ต้องเป็น string |
| 400 | ไม่มีทั้ง fileId และ typeId |
ต้องระบุ fileId หรือ typeId อย่างน้อยหนึ่งอย่าง |
| 400 | filter ไม่ใช่ object / มี field อื่น |
filter ต้องเป็น object เช่น { "type": "bookmarks" } / filter รับเฉพาะ field 'type', 'userId', 'annotationTypes' |
| 400 | filter.type ค่าไม่รู้จัก (รวมเอกพจน์ annotation/bookmark) |
filter.type รับค่า bookmarks หรือ annotations เท่านั้น |
| 400 | filter.userId ไม่ใช่ string/array ของ string |
filter.userId ต้องเป็น string หรือ array ของ string |
| 400 | filter.annotationTypes ค่านอก enum |
filter.annotationTypes รับค่า HIGHLIGHT, DRAW, COMMENT, SHAPE, REDACTION, TEXTBOX, STAMP เท่านั้น |
| 401 | ไม่ใส่ x-api-key หรือ key ผิด |
API key ไม่ถูกต้อง (ข้อความเดียวกันกัน enumeration) |
| 401 | ฝั่ง server ยังไม่ตั้ง key | API ยังไม่ได้ตั้งค่าคีย์ |
| 404 | ถาม fileId ที่ไม่เคยเปิดในระบบ |
ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด) |
| 404 | ส่งคู่ fileId+typeId แต่ไม่ตรงคู่ |
ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ) |
| 500 | ระบบนี้มีปัญหาฐานข้อมูล ณ ตอนนั้น | เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง (รายละเอียดอยู่ใน log ฝั่ง server อย่างเดียว) |
การแยก "ไม่มีข้อมูล" ออกจาก "ผิดพลาด":
- เอกสารเคยเปิดแต่ไม่มีรายการ → 200 พร้อม
annotations: []/bookmarks: [] - กลุ่มไม่มีเอกสารเลย → 200 พร้อม
documents: [] filter.userIdไม่ตรงกับผู้ใช้ใดในระบบ → 200 รายการว่าง (ไม่ใช่ 400)
7. ตัวอย่างการเรียก
7.1 เอกสารเดียว เอาทุกอย่าง
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234"}' \
"http://localhost:3000/docedit/api/note/external/documents"
Response (ตัดย่อ):
{
"documents": [
{
"id": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"externalFileId": "1234",
"title": "คำพิพากษา ผบ.123/2569",
"typeId": "CASE-2569-0001",
"createdAt": "2026-09-24T09:15:33.120Z",
"annotations": [
{
"id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"fileId": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"pageIndex": 0,
"userId": "u_1234",
"userName": "somchai.j",
"type": "COMMENT",
"data": { "position": { "x": 41.2, "y": 37.8 }, "message": "ข้อเท็จจริงตรงนี้ขัดกับคำให้การ ร.2" },
"createdAt": "2026-09-24T10:02:11.530Z",
"updatedAt": "2026-09-24T10:02:11.530Z",
"resolved": false,
"resolvedAt": null,
"url": "http://localhost:3000/docedit/note/annotate/3f2a9c1e-…?ann=7c1d2e3f-…",
"replies": [
{
"id": "9e8f0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
"annotationId": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"userId": "u_2200",
"userName": "somsri.k",
"message": "บันทึกไว้ก่อน ค่อยถามผู้เบิกความวันพรุ่งนี้",
"createdAt": "2026-09-25T08:44:02.310Z"
}
]
}
],
"bookmarks": [
{
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"fileId": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"pageIndex": 2,
"userId": "u_1234",
"userName": "somchai.j",
"title": "บทวินิจฉัย",
"parentId": null,
"note": null,
"yOffset": 12.4,
"createdAt": "2026-09-24T10:05:47.900Z",
"url": "http://localhost:3000/docedit/note/annotate/3f2a9c1e-…?page=3"
}
]
}
]
}
7.2 เอกสารเดียว เฉพาะ bookmark ของผู้ใช้หนึ่ง
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
"http://localhost:3000/docedit/api/note/external/documents"
(userId เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
7.3 ทุกเอกสารในกลุ่ม เฉพาะ COMMENT และ HIGHLIGHT
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
"http://localhost:3000/docedit/api/note/external/documents"
ได้ documents หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย externalFileId: null
7.4 fileId + typeId พร้อมกัน (AND)
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/api/note/external/documents"
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
8. ข้อควรรู้/ข้อจำกัด
- เอกสารต้องเคยเปิดผ่านลิงก์ e-Court ก่อนอย่างน้อยหนึ่งครั้ง — ระบบนี้ลงทะเบียนเอกสาร (metadata-only) เมื่อมีผู้เปิดจากลิงก์
?fileId=&fileUrl=&typeId=ครั้งแรกเท่านั้น ยังไม่เคยเปิด = 404 (ไม่ใช่ error ฝั่ง e-Court) · ลิงก์ที่ไม่มีfileId(ส่งแต่fileUrl) จะค้นด้วยวิธีนี้ไม่ได้ pageIndexเป็น 0-based ส่วน?page=ใน url เป็น 1-based —pageIndex: 0กับ?page=1คือหน้าเดียวกันurlอ่าน origin จาก request ของผู้เรียก — ถ้าระบบนี้ deploy หลัง reverse proxy ต้องตั้งให้ proxy ส่งต่อx-forwarded-proto/x-forwarded-hostให้ถูกต้อง ไม่งั้นลิงก์จะชี้ origin ของ proxy แทน public URL- timestamp เป็น ISO 8601 (UTC) ทุกจุด
- ไม่มี pagination/rate limiting/caching — กลุ่มใหญ่ได้ response ยาวตามจำนวนรายการจริง
- ไม่มี API ฝั่งเขียน (สร้าง/แก้ annotation ผ่าน API) และไม่มีการดึงไฟล์ PDF/bytes ผ่าน endpoint นี้ — ข้อมูลที่ได้มีเฉพาะ metadata + พิกัด + ข้อความของ annotation/bookmark