eCourt-note/docs/API-DOCUMENTS.md
Your Name 8cc15ea75d
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
refactor(routes): move routes under /note, APIs under /api/note
เตรียม 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>
2026-10-06 14:55:07 +07:00

23 KiB
Raw Blame History

คู่มือการใช้ 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. ข้อควรรู้/ข้อจำกัด

  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