From 882325b58af216cc01ad9d99371dc419582c8af2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 13:19:43 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20=E0=B8=84=E0=B8=B9=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=B7=E0=B8=AD=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=20N10=20Documents=20API=20=E0=B8=AA=E0=B8=B3=E0=B8=AB?= =?UTF-8?q?=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B8=97=E0=B8=B5=E0=B8=A1=20backen?= =?UTF-8?q?d=20e-Court=20(docs/API-DOCUMENTS.md)=20=E2=80=94=20=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=E0=B8=AA=E0=B9=88?= =?UTF-8?q?=E0=B8=87=E0=B8=95=E0=B9=88=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89?= =?UTF-8?q?=E0=B9=80=E0=B8=A3=E0=B8=B5=E0=B8=A2=E0=B8=81=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=20=E0=B9=81=E0=B8=A2=E0=B8=81=E0=B8=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=20docs/PLAN-API.md=20=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=AA=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B8=81/=E0=B9=80=E0=B8=AB=E0=B8=95=E0=B8=B8?= =?UTF-8?q?=E0=B8=9C=E0=B8=A5=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=AD=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B9=81=E0=B8=9A=E0=B8=9A=E0=B8=A0=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B9=83=E0=B8=99:=20=E0=B9=80=E0=B8=99=E0=B8=B7=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=AB=E0=B8=B2=E0=B9=80=E0=B8=82=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=B2=E0=B8=81=E0=B9=82=E0=B8=84=E0=B9=89?= =?UTF-8?q?=E0=B8=94=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=97=E0=B8=B8?= =?UTF-8?q?=E0=B8=81=20field=20(route=20+=20lib/external-api.ts=20+=20lib/?= =?UTF-8?q?external-api-auth.ts=20+=20db/schema.ts=20DTO)=20=E2=80=94=20(1?= =?UTF-8?q?)=20=E0=B8=A0=E0=B8=B2=E0=B8=9E=E0=B8=A3=E0=B8=A7=E0=B8=A1=203?= =?UTF-8?q?=20=E0=B9=81=E0=B8=9A=E0=B8=9A=E0=B8=81=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B8=96=E0=B8=B2=E0=B8=A1=20(fileId=20=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=20/=20typeId=20=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=20/=20=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=84=E0=B8=B9?= =?UTF-8?q?=E0=B9=88=20=3D=20AND)=20+=20URL=20=E0=B9=80=E0=B8=95=E0=B9=87?= =?UTF-8?q?=E0=B8=A1=E0=B9=83=E0=B8=95=E0=B9=89=20basePath=20/docedit/note?= =?UTF-8?q?=20+=20=E0=B9=80=E0=B8=AB=E0=B8=95=E0=B8=B8=E0=B8=9C=E0=B8=A5?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=89=20POST=20(=E0=B8=96=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20JSON=20body=20?= =?UTF-8?q?=E2=80=94=20GET=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=81=E0=B8=99?= =?UTF-8?q?=E0=B8=9A=20body=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=B5=20defined=20semantics=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20RFC=209110=20proxy/gateway=20=E0=B8=AD=E0=B8=B2=E0=B8=88?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B8=94=E0=B8=97=E0=B8=B4=E0=B9=89=E0=B8=87?= =?UTF-8?q?)=20(2)=20auth=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20header?= =?UTF-8?q?=20x-api-key:=20=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=20401=20=E0=B8=8B=E0=B9=89=E0=B8=B3=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=99=20enumerate=20+?= =?UTF-8?q?=20key=20=E0=B9=80=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=96?= =?UTF-8?q?=E0=B8=B7=E0=B8=AD=E0=B8=A3=E0=B8=B0=E0=B8=94=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=20backend=20=E0=B9=80=E0=B8=97=E0=B9=88=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99=20(=E0=B9=80=E0=B8=AB=E0=B9=87?= =?UTF-8?q?=E0=B8=99=E0=B8=95=E0=B8=B3=E0=B9=81=E0=B8=AB=E0=B8=99=E0=B9=88?= =?UTF-8?q?=E0=B8=87=20REDACTION)=20(3)=20request=20body=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20field=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B8=94=E0=B8=B1=E0=B8=9A=E0=B8=9A=E0=B8=99=E0=B8=AA?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=20+=20filter=20=E0=B8=97=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=203=20=E0=B8=A1=E0=B8=B4=E0=B8=95=E0=B8=B4=20(type/us?= =?UTF-8?q?erId/annotationTypes)=20=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD?= =?UTF-8?q?=E0=B8=A1=E0=B8=81=E0=B8=95=E0=B8=B4=E0=B8=81=E0=B8=B2=20trim?= =?UTF-8?q?=20=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B8=A7=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=20=3D=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=AA=E0=B9=88=E0=B8=87=20+=20strict=20validation?= =?UTF-8?q?=20400=20=E0=B8=81=E0=B8=B1=E0=B8=99=20typo=20(4)=20response=20?= =?UTF-8?q?200:=20=E0=B8=95=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20field=20?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3/annotati?= =?UTF-8?q?on/bookmark/reply=20+=20data=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B8=8A=E0=B8=99=E0=B8=B4=E0=B8=94=20annotation=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=87=207=20(=E0=B8=9E=E0=B8=B4=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=20%=20=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2)=20+=20=E0=B8=A5=E0=B8=B3=E0=B8=94?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=20(annotations=20pageIndex+createdAt=20/=20bookmarks?= =?UTF-8?q?=20N5=20/=20replies=20createdAt=20/=20documents=20files.created?= =?UTF-8?q?At)=20+=20=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B8=B2=E0=B8=A2=20url=20deep=20link=20=E0=B9=80?= =?UTF-8?q?=E0=B8=95=E0=B9=87=E0=B8=A1=20(=3Fann=3D=20/=20=3Fpage=3D=201-b?= =?UTF-8?q?ased)=20(5)=20=E0=B8=95=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20e?= =?UTF-8?q?rror=20=E0=B8=97=E0=B8=B8=E0=B8=81=20status=20=E0=B8=9E?= =?UTF-8?q?=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=82=E0=B9=89=E0=B8=AD?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=97=E0=B8=A2?= =?UTF-8?q?=E0=B8=95=E0=B8=A3=E0=B8=87=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=82=E0=B8=84=E0=B9=89=E0=B8=94=E0=B8=95?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20+=20?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=81=E0=B8=A2=E0=B8=81=20'?= =?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5=E0=B8=82=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A1=E0=B8=B9=E0=B8=A5'=20(200=20+=20array=20?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=87)=20=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20'=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B8=9E=E0=B8=A5?= =?UTF-8?q?=E0=B8=B2=E0=B8=94'=20(6)=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2=E0=B8=87=20curl=204=20?= =?UTF-8?q?=E0=B9=81=E0=B8=9A=E0=B8=9A=20=E0=B8=9E=E0=B8=A3=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A1=20response=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=95?= =?UTF-8?q?=E0=B9=87=E0=B8=A1=E0=B8=A3=E0=B8=B9=E0=B8=9B=20annotation+repl?= =?UTF-8?q?y+bookmark=20(7)=20=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84?= =?UTF-8?q?=E0=B8=A7=E0=B8=A3=E0=B8=A3=E0=B8=B9=E0=B9=89:=20=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B8=B4=E0=B8=94=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B8=A5?= =?UTF-8?q?=E0=B8=B4=E0=B8=87=E0=B8=81=E0=B9=8C=20e-Court=20=E0=B8=81?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=99,=20pageIndex=200-based=20vs=20=3Fpa?= =?UTF-8?q?ge=3D=201-based,=20reverse=20proxy=20=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=AA=E0=B9=88=E0=B8=87=20x-forwarded-pro?= =?UTF-8?q?to/host=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=96=E0=B8=B9?= =?UTF-8?q?=E0=B8=81=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=87=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=99=20url=20=E0=B8=8A=E0=B8=B5=E0=B9=89=20origin=20?= =?UTF-8?q?=E0=B8=9C=E0=B8=B4=E0=B8=94,=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=20pagination/write=20API=20=E2=80=94=20READM?= =?UTF-8?q?E=20=E0=B9=81=E0=B8=81=E0=B9=89=E0=B8=88=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7:=20=E0=B8=AB?= =?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B8=82=E0=B9=89=E0=B8=AD=20N10=20=E0=B8=8A?= =?UTF-8?q?=E0=B8=B5=E0=B9=89=E0=B8=A1=E0=B8=B2=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B8=84=E0=B8=B9=E0=B9=88=E0=B8=A1=E0=B8=B7=E0=B8=AD=20?= =?UTF-8?q?=E0=B9=82=E0=B8=94=E0=B8=A2=E0=B8=84=E0=B8=87=E0=B8=A5=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=E0=B8=81=E0=B9=8C=20PLAN-API.md=20=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=E0=B8=AA=E0=B9=80=E0=B8=9B=E0=B8=81=20(?= =?UTF-8?q?=E0=B8=84=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=89=202026-09-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- README.md | 2 +- docs/API-DOCUMENTS.md | 291 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 docs/API-DOCUMENTS.md diff --git a/README.md b/README.md index d5fdf07..003bc6c 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร ## API สำหรับระบบภายนอก (N10) -Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — รายละเอียดเต็ม: `docs/PLAN-API.md` +Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md` ```jsonc // POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath) diff --git a/docs/API-DOCUMENTS.md b/docs/API-DOCUMENTS.md new file mode 100644 index 0000000..2cf5700 --- /dev/null +++ b/docs/API-DOCUMENTS.md @@ -0,0 +1,291 @@ +# คู่มือการใช้ Documents API (N10) + +> สำหรับ: ทีม backend ของ e-Court ที่จะเรียก API นี้แบบ **server-to-server** (ไม่มี browser/หน้า UI) +> ใช้กับโค้ดเวอร์ชัน 2026-09-25 · สเปกฉบับเต็ม/เหตุผลการออกแบบ: [`docs/PLAN-API.md`](./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:///docedit/note/api/external/documents` (แอป serve ใต้ basePath `/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: + +```http +POST /docedit/note/api/external/documents HTTP/1.1 +Host: +x-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 + +```jsonc +{ + "fileId": "", // ถามเอกสารเดียว + "typeId": "", // ถามทุกเอกสารในกลุ่ม + "filter": { // optional ทั้งก้อน + "type": "bookmarks", // "bookmarks" | "annotations" — ไม่ส่ง = ทั้งสอง + "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/?ann=`) — เป็น **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/?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 '' (รับเฉพาะ 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 เอกสารเดียว เอาทุกอย่าง + +```bash +curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \ + -d '{"fileId":"1234"}' \ + "http://localhost:3000/docedit/note/api/external/documents" +``` + +Response (ตัดย่อ): + +```jsonc +{ + "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 ของผู้ใช้หนึ่ง + +```bash +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/note/api/external/documents" +``` + +(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้) + +### 7.3 ทุกเอกสารในกลุ่ม เฉพาะ COMMENT และ HIGHLIGHT + +```bash +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/note/api/external/documents" +``` + +ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null` + +### 7.4 fileId + typeId พร้อมกัน (AND) + +```bash +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/note/api/external/documents" +``` + +ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404 + +## 8. ข้อควรรู้/ข้อจำกัด + +1. **เอกสารต้องเคยเปิดผ่านลิงก์ e-Court ก่อนอย่างน้อยหนึ่งครั้ง** — ระบบนี้ลงทะเบียนเอกสาร (metadata-only) เมื่อมีผู้เปิดจากลิงก์ `?fileId=&fileUrl=&typeId=` ครั้งแรกเท่านั้น ยังไม่เคยเปิด = 404 (ไม่ใช่ error ฝั่ง e-Court) · ลิงก์ที่ไม่มี `fileId` (ส่งแต่ `fileUrl`) จะค้นด้วยวิธีนี้ไม่ได้ +2. **`pageIndex` เป็น 0-based ส่วน `?page=` ใน url เป็น 1-based** — `pageIndex: 0` กับ `?page=1` คือหน้าเดียวกัน +3. **`url` อ่าน origin จาก request ของผู้เรียก** — ถ้าระบบนี้ deploy หลัง reverse proxy ต้องตั้งให้ proxy ส่งต่อ `x-forwarded-proto`/`x-forwarded-host` ให้ถูกต้อง ไม่งั้นลิงก์จะชี้ origin ของ proxy แทน public URL +4. **timestamp เป็น ISO 8601 (UTC) ทุกจุด** +5. ไม่มี pagination/rate limiting/caching — กลุ่มใหญ่ได้ response ยาวตามจำนวนรายการจริง +6. ไม่มี API ฝั่งเขียน (สร้าง/แก้ annotation ผ่าน API) และไม่มีการดึงไฟล์ PDF/bytes ผ่าน endpoint นี้ — ข้อมูลที่ได้มีเฉพาะ metadata + พิกัด + ข้อความของ annotation/bookmark