// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI // แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver // (จัด version เอกสาร) // route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้ // — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก) // // URL ใน paths เป็น path เต็มรวม /api/… ตาม invariant #12 (URL จริง // /docedit/api/… — servers เติมแค่ basePath ไม่มี /note ซ้อน) const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const ANNOTATION_TYPE_ENUM = [ "HIGHLIGHT", "DRAW", "COMMENT", "SHAPE", "REDACTION", "TEXTBOX", "STAMP", ] as const; export const openApiSpec = { openapi: "3.0.3", info: { title: "Document Template & External API ของระบบ Judge's Note", version: "1.0.0", description: "API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " + "API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)", }, servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }], tags: [ { name: "template", description: "Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " + "แล้วได้เอกสารที่กรอกค่าแล้วกลับมา", }, { name: "documents", description: "จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " + "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — ไม่มี auth (stand-alone): " + "ผู้เรียกบอก userId/userName เองใน multipart", }, { name: "Judge's Note", description: "API ภายนอก (N10) — backend e-Court เรียกแบบ server-to-server ของ (Judge's Note)" + "ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้", }, ], paths: { "/api/template/test": { get: { tags: ["template"], summary: "ตรวจสอบว่า service มีชีวิต", responses: { 200: { description: "service พร้อมใช้งาน", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, service: { type: "string" }, }, }, }, }, }, }, }, }, "/api/template/extract": { post: { tags: ["template"], summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx", description: "อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " + "ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)", requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["file"], properties: { file: { type: "string", format: "binary", description: "ไฟล์ .docx ที่มี Content Control", }, secret: { type: "string", description: "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property" , example: "super_secret", }, public_key: { type: "string", description: "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", example: "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", }, }, }, }, }, }, responses: { 200: { description: "รายการแท็กพร้อมข้อความ", content: { "application/json": { schema: { $ref: "#/components/schemas/ExtractResponse" }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 500: { $ref: "#/components/responses/ServerError" }, }, }, }, "/api/template/render": { post: { tags: ["template"], summary: "สร้างเอกสารจาก template (.docx) + data (JSON)", description: "แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " + '(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' + 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)', requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["template"], properties: { template: { type: "string", format: "binary", description: "ไฟล์ .docx ที่เป็น template (มี Content Control)", }, data: { type: "string", format: "binary", description: 'ไฟล์ JSON เช่น { "blackno": "คดีหมายเลขดำที่", … }', }, dataJson: { type: "string", description: "JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)
{\"info:\"{\"ticket_data\":\"JSON String\"},\"dc:\"{..},\"dc:\"{\"blackyear\": \"๙๐/๒๕๖๙\",...}}", example: '', }, secret: { type: "string", description: "ค่า secret สำหรับทดสอบการทำ HMAC-SHA256", example: 'super_secret', }, private_key: { type: "string", description: "Private key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", example: '8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1', }, }, }, }, }, }, responses: { 200: { description: "ไฟล์ .docx ที่กรอกค่าแล้ว (binary)", content: { "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { schema: { type: "string", format: "binary" }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 500: { $ref: "#/components/responses/ServerError" }, }, }, }, "/api/note/external/documents": { post: { tags: ["external"], summary: "ดึง annotations/bookmarks ของเอกสาร (N10)", description: "ถามด้วย fileId (เอกสารเดียว — id ของ e-Court ที่ลิงก์มา) และ/หรือ typeId " + "(ทุกเอกสารในกลุ่ม) — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข " + "· response รูปเดียวเสมอ { documents: […] } (กลุ่มว่าง = array ว่าง) " + "· read-only: snapshot ข้อมูล ณ ตอนเรียก · แต่ละรายการมี url deep link กลับหน้าอ่านของระบบนี้", security: [{ ApiKeyAuth: [] }], requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ExternalQuery" }, examples: { "ถามด้วย fileId (เอกสารเดียว)": { value: { fileId: "DOC-2569-0001" }, }, "ทุกเอกสารในกลุ่ม": { value: { typeId: "GROUP-01" }, }, "กลุ่ม + เฉพาะ bookmark ของคนใดคนหนึ่ง": { value: { typeId: "GROUP-01", filter: { type: "bookmarks", userId: "u0001" }, }, }, }, }, }, }, responses: { 200: { description: "bundle ของเอกสารที่ตรงเงื่อนไข", content: { "application/json": { schema: { $ref: "#/components/schemas/DocumentsResponse" }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 401: { description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, 404: { description: "ไม่พบเอกสาร (ยังไม่เคยเปิดในระบบ หรือไม่อยู่ในกลุ่ม typeId ที่ระบุ)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, 500: { $ref: "#/components/responses/ServerError" }, }, }, }, "/api/documents/{docId}/versions": { post: { tags: ["documents"], summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)", description: "สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " + ".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " + "คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " + "optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response)", parameters: [ { name: "docId", in: "path", required: true, schema: { type: "string" }, description: "รหัสเอกสารจากผู้เรียก (add-in GUID / รหัส e-Court) — [A-Za-z0-9._:-]", }, ], requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["file", "userId", "userName"], properties: { file: { type: "string", format: "binary", description: "ไฟล์ .docx ของ version นี้ (บังคับ)", }, pdf: { type: "string", format: "binary", description: "ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)", }, userId: { type: "string", description: "รหัสผู้อัปโหลด (ระบบ stand-alone ไม่มี session)", }, userName: { type: "string", description: "ชื่อผู้อัปโหลด (แสดงใน version history)", }, clientVersionId: { type: "string", description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)", }, fields: { type: "string", description: 'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}', }, message: { type: "string", description: "commit message ของ version (≤ 2048 ตัวอักษร)", }, templateId: { type: "string", description: "รหัส template ที่ใช้สร้างเอกสาร (ถ้ามี)", }, fileName: { type: "string", description: "ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)", }, }, }, }, }, }, responses: { 200: { description: "version ถูกบันทึกแล้ว", content: { "application/json": { schema: { $ref: "#/components/schemas/UploadVersionResult" }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 413: { description: "ไฟล์แนบใหญ่เกิน 100MB", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, 500: { $ref: "#/components/responses/ServerError" }, }, }, get: { tags: ["documents"], summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)", description: "version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " + "message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in", parameters: [ { name: "docId", in: "path", required: true, schema: { type: "string" }, description: "รหัสเอกสารจากผู้เรียก — [A-Za-z0-9._:-]", }, ], responses: { 200: { description: "รายการ version เรียงใหม่ → เก่า", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/VersionRecord" }, }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 404: { description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, 500: { $ref: "#/components/responses/ServerError" }, }, }, }, "/api/documents/{docId}/versions/{versionId}/download-link": { get: { tags: ["documents"], summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)", description: "คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " + "เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " + "ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404", parameters: [ { name: "docId", in: "path", required: true, schema: { type: "string" }, description: "รหัสเอกสาร (ต้องตรงกับ version นี้ — โยน id ข้ามเอกสารไม่ได้)", }, { name: "versionId", in: "path", required: true, schema: { type: "string", format: "uuid" }, description: "id ของ version (field versionId ใน VersionRecord)", }, { name: "format", in: "query", required: false, schema: { type: "string", enum: ["docx", "pdf"], default: "docx" }, description: "ชนิดไฟล์ที่จะดาวน์โหลด (ไม่ส่ง = docx)", }, ], responses: { 200: { description: "presigned URL พร้อมเวลาหมดอายุ", content: { "application/json": { schema: { $ref: "#/components/schemas/DownloadLink" }, }, }, }, 400: { $ref: "#/components/responses/BadRequest" }, 404: { description: "ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, 500: { $ref: "#/components/responses/ServerError" }, }, }, }, }, components: { securitySchemes: { ApiKeyAuth: { type: "apiKey", in: "header", name: "x-api-key", description: "ค่าเดียวกับ env EXTERNAL_API_KEY ของ server — กดปุ่ม Authorize ด้านบนเพื่อใส่ครั้งเดียวแล้วใช้ทุก request", }, }, responses: { BadRequest: { description: "request ไม่ถูกต้อง (field หาย / ชนิดไฟล์ผิด / ค่า enum ไม่รู้จัก / " + "JSON ผิด format / ไฟล์แนบใหญ่เกิน 100MB)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, ServerError: { description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, }, }, }, }, schemas: { Error: { type: "object", properties: { success: { type: "boolean", example: false }, error: { type: "string" }, }, }, ExtractResponse: { type: "object", properties: { success: { type: "boolean", example: true }, data: { type: "object", description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)", additionalProperties: { type: "string" }, example: { blackno: "คดีหมายเลขดำที่", court: "ศาลปกครองสุพรรณบุรี", }, }, }, }, ExternalQuery: { type: "object", description: "ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง (ส่งทั้งคู่ได้ = AND เหลือเอกสารเดียว)", properties: { fileId: { type: "string", description: "id เอกสารของ e-Court — ตรงกับที่ส่งมากับลิงก์ ?fileId= ตอนเปิดเอกสาร", }, typeId: { type: "string", description: "รหัสกลุ่มเอกสาร — คืนทุกไฟล์ในกลุ่มนี้ (เรียงตามวันที่สร้าง)", }, filter: { type: "object", properties: { type: { type: "string", enum: ["bookmarks", "annotations"], description: "เลือกเฉพาะชนิดรายการ (ไม่ส่ง = ทั้งสอง ส่วนที่ไม่เลือกคืนเป็น array ว่าง)", }, userId: { description: "กรองตามเจ้าของรายการ (string เดี่ยวหรือ array)", oneOf: [ { type: "string" }, { type: "array", items: { type: "string" } }, ], }, annotationTypes: { description: "กรองชนิดของ annotation — มีผลเฉพาะเมื่อรวม annotations", oneOf: [ { type: "string", enum: [...ANNOTATION_TYPE_ENUM] }, { type: "array", items: { type: "string", enum: [...ANNOTATION_TYPE_ENUM] }, }, ], }, }, }, }, }, DocumentsResponse: { type: "object", properties: { documents: { type: "array", items: { $ref: "#/components/schemas/DocumentBundle" }, }, }, }, DocumentBundle: { type: "object", description: "field ของเอกสารอยู่ระดับเดียวกับ annotations/bookmarks (bundle ยุบชั้น)", properties: { id: { type: "string", format: "uuid" }, externalFileId: { type: "string", nullable: true, description: "id ของ e-Court (null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)", }, title: { type: "string" }, typeId: { type: "string", nullable: true }, createdAt: { type: "string", format: "date-time" }, annotations: { type: "array", items: { $ref: "#/components/schemas/ExternalAnnotation" }, }, bookmarks: { type: "array", items: { $ref: "#/components/schemas/ExternalBookmark" }, }, }, }, ExternalAnnotation: { type: "object", properties: { id: { type: "string", format: "uuid" }, fileId: { type: "string", format: "uuid" }, pageIndex: { type: "integer", description: "0-based" }, userId: { type: "string" }, userName: { type: "string" }, type: { type: "string", enum: [...ANNOTATION_TYPE_ENUM] }, data: { type: "object", description: "ข้อมูลเฉพาะชนิด (พิกัดเป็น % ของขนาดหน้า) เช่น HIGHLIGHT { text, color, rects }, COMMENT { message, position }", }, createdAt: { type: "string", format: "date-time" }, updatedAt: { type: "string", format: "date-time" }, resolved: { type: "boolean" }, resolvedAt: { type: "string", format: "date-time", nullable: true }, url: { type: "string", description: "deep link กลับหน้าอ่าน พร้อม ?ann= ของรายการนี้", }, replies: { type: "array", items: { $ref: "#/components/schemas/ExternalReply" }, }, }, }, ExternalBookmark: { type: "object", properties: { id: { type: "string", format: "uuid" }, fileId: { type: "string", format: "uuid" }, pageIndex: { type: "integer", description: "0-based" }, userId: { type: "string" }, userName: { type: "string" }, title: { type: "string" }, parentId: { type: "string", nullable: true, description: "null = ระดับบนสุด (มีค่า = sub ของ bookmark นั้น)", }, note: { type: "string", nullable: true }, yOffset: { type: "number", description: "ตำแหน่งบนหน้า (% ของความสูง)" }, createdAt: { type: "string", format: "date-time" }, url: { type: "string", description: "deep link กลับหน้าอ่าน พร้อม ?page= 1-based ของหน้านี้", }, }, }, ExternalReply: { type: "object", properties: { id: { type: "string", format: "uuid" }, annotationId: { type: "string", format: "uuid" }, userId: { type: "string" }, userName: { type: "string" }, message: { type: "string" }, createdAt: { type: "string", format: "date-time" }, }, }, UploadVersionResult: { type: "object", description: "ตอบกลับหลังอัปโหลด version สำเร็จ", properties: { hasPdf: { type: "boolean", description: "true = version นี้มีสำเนา PDF" }, versionId: { type: "string", format: "uuid" }, versionNumber: { type: "integer", description: "เลข version ที่ server จ่าย (ค่าจริง — ไม่ใช่ที่ client คาด)", }, }, }, VersionRecord: { type: "object", description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)", properties: { versionId: { type: "string", format: "uuid" }, versionNumber: { type: "integer" }, createdAt: { type: "string", format: "date-time" }, createdByUserId: { type: "string" }, createdByUserName: { type: "string" }, contentHash: { type: "string", description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)", }, fields: { type: "object", nullable: true, description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)", additionalProperties: { type: "string" }, }, message: { type: "string", nullable: true, description: "commit message" }, hasPdf: { type: "boolean" }, }, }, DownloadLink: { type: "object", properties: { url: { type: "string", description: "presigned URL ของ MinIO (GET โดยตรง)" }, expiresAt: { type: "string", format: "date-time" }, }, }, }, }, } as const;