feat(api): add swagger ui and docx template api
เพิ่มกลุ่ม API docx template สำหรับ wordaddin (WIP): - POST /api/template/extract ดึงค่า Content Control ทั้งหมดจาก .docx - POST /api/template/render แทนค่า JSON ลง template คืน .docx ใหม่ - GET /api/template/test ตรวจว่า service มีชีวิต logic แชร์ที่ app/api/template/lib-tmpl.ts (pizzip+xpath) ไม่แตะ DB เพิ่มหน้า Swagger UI ที่ GET /api/swagger — spec inline จาก lib/openapi.ts ครอบ API ทุกกลุ่ม (template + N10), asset self-host ที่ public/note/swagger-ui/ จาก postinstall scripts/copy-swagger-ui.mjs (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 เหมือน patch-pdf-worker, Docker build รัน script นี้ด้วยผ่าน web.Dockerfile) middleware เปิดกว้างให้ api/template, api/swagger, note/swagger-ui จาก code review: จำกัดไฟล์แนบ 100MB ทั้ง extract/render ก่อนอ่าน เข้าหน่วยความจำกัน DoS, คืน 400 แทน 500 เมื่อ JSON ผิด format, sync คำอธิบาย BadRequest ใน openapi spec ให้ตรง behavior ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
8cc15ea75d
commit
073f72db7d
20 changed files with 1479 additions and 10 deletions
|
|
@ -34,9 +34,10 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
||||||
|
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
||||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, note/uploads/*, note/samples, note/pdf.worker.min.mjs, _next, fonts, favicon)
|
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon)
|
||||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
||||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||||
|
|
@ -44,6 +45,7 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย
|
||||||
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม)
|
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม)
|
||||||
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
||||||
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
||||||
|
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
||||||
|
|
@ -64,6 +66,8 @@ app/actions/files.ts ← Server Action uploadFile — validate + เขีย
|
||||||
app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
||||||
app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
||||||
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
||||||
|
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
|
||||||
|
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
|
||||||
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||||
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
||||||
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
|
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
|
||||||
|
|
|
||||||
44
app/api/swagger/route.ts
Normal file
44
app/api/swagger/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { openApiSpec } from "@/lib/openapi";
|
||||||
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
|
// GET /api/swagger — หน้า Swagger UI ของ API ทั้งหมด (กลุ่ม template + N10)
|
||||||
|
// (asset สำหรับ intranet: postinstall scripts/copy-swagger-ui.mjs คัดลอก
|
||||||
|
// swagger-ui-dist ไป public/note/swagger-ui/ — pattern เดียวกับ pdf.worker)
|
||||||
|
// spec inline จาก lib/openapi.ts ตรง ๆ ไม่มี endpoint spec แยก
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// กัน </script> ใน JSON ตัดสคริปต์ขาย (spec เป็นของเราเองทั้งหมด แค่กันกรณี
|
||||||
|
// ข้อความ example มี < ในอนาคต)
|
||||||
|
const SPEC_JSON = JSON.stringify(openApiSpec).replace(/</g, "\\u003c");
|
||||||
|
|
||||||
|
const HTML = `<!doctype html>
|
||||||
|
<html lang="th">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Swagger UI — Judge's Note API</title>
|
||||||
|
<link rel="stylesheet" href="${withBasePath("/swagger-ui/swagger-ui.css")}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="${withBasePath("/swagger-ui/swagger-ui-bundle.js")}"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function () {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
spec: ${SPEC_JSON},
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
deepLinking: true,
|
||||||
|
persistAuthorization: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return new NextResponse(HTML, {
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
75
app/api/template/extract/route.ts
Normal file
75
app/api/template/extract/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { extractContentControls } from "../lib-tmpl";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
||||||
|
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||||
|
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||||
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Missing DOCX file in field "file".',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
file.type !== DOCX_CONTENT_TYPE &&
|
||||||
|
!file.name.toLowerCase().endsWith(".docx")
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "file" must be a .docx file.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "file" exceeds the 100MB size limit.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File → Buffer
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// ใช้ lib-tmpl.ts เดิม
|
||||||
|
const data = extractContentControls(buffer);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Internal Server Error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
518
app/api/template/lib-tmpl.ts
Normal file
518
app/api/template/lib-tmpl.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
import PizzaZip from "pizzip";
|
||||||
|
import {
|
||||||
|
DOMParser,
|
||||||
|
XMLSerializer,
|
||||||
|
type Document as XmlDocument,
|
||||||
|
type Element as XmlElement,
|
||||||
|
type Node as XmlNode,
|
||||||
|
type Attr as XmlAttr,
|
||||||
|
} from "@xmldom/xmldom";
|
||||||
|
import xpath from "xpath";
|
||||||
|
|
||||||
|
export interface ExtractedData {
|
||||||
|
[tagName: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W_NS =
|
||||||
|
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||||
|
|
||||||
|
const asXpathNode = (node: XmlNode): globalThis.Node =>
|
||||||
|
node as unknown as globalThis.Node;
|
||||||
|
|
||||||
|
function createSelector() {
|
||||||
|
return xpath.useNamespaces({ w: W_NS });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อ่าน Content Control (w:sdt) จาก DOCX Buffer
|
||||||
|
* ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ
|
||||||
|
*/
|
||||||
|
export function extractContentControls(
|
||||||
|
docxBuffer: Buffer,
|
||||||
|
): ExtractedData {
|
||||||
|
const zip = new PizzaZip(docxBuffer);
|
||||||
|
const docXmlFile = zip.file("word/document.xml");
|
||||||
|
|
||||||
|
if (!docXmlFile) {
|
||||||
|
throw new Error("Invalid docx file: word/document.xml not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
||||||
|
const select = createSelector();
|
||||||
|
const result: ExtractedData = {};
|
||||||
|
|
||||||
|
const sdtNodes = select(
|
||||||
|
"//w:sdt",
|
||||||
|
asXpathNode(doc),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
sdtNodes.forEach((sdt) => {
|
||||||
|
const tagAttr = select(
|
||||||
|
"./w:sdtPr/w:tag/@w:val",
|
||||||
|
asXpathNode(sdt),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlAttr | undefined;
|
||||||
|
|
||||||
|
const tagName = tagAttr?.value;
|
||||||
|
|
||||||
|
if (!tagName) return;
|
||||||
|
|
||||||
|
const sdtContent = select(
|
||||||
|
"./w:sdtContent",
|
||||||
|
asXpathNode(sdt),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (!sdtContent) {
|
||||||
|
result[tagName] = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = select(
|
||||||
|
".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym",
|
||||||
|
asXpathNode(sdtContent),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
let extractedText = "";
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
switch (node.nodeName) {
|
||||||
|
case "w:p":
|
||||||
|
if (
|
||||||
|
extractedText.length > 0 &&
|
||||||
|
!extractedText.endsWith("\n")
|
||||||
|
) {
|
||||||
|
extractedText += "\n";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:t":
|
||||||
|
extractedText += node.textContent || "";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:tab":
|
||||||
|
extractedText += "\t";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:br":
|
||||||
|
extractedText += "\n";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:noBreakHyphen":
|
||||||
|
extractedText += "\u2011";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:softHyphen":
|
||||||
|
extractedText += "\u00ad";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "w:sym": {
|
||||||
|
const charAttr = node.getAttribute("w:char");
|
||||||
|
if (charAttr) {
|
||||||
|
const charCode = Number.parseInt(charAttr, 16);
|
||||||
|
extractedText += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
result[tagName] = extractedText;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SdtControlType = "plain" | "rich" | "other";
|
||||||
|
type SdtStructure = "block" | "inline";
|
||||||
|
|
||||||
|
function detectSdtControlType(
|
||||||
|
sdt: XmlElement,
|
||||||
|
select: xpath.XPathSelect,
|
||||||
|
): { type: SdtControlType; multiLine: boolean } {
|
||||||
|
const sdtPr = select(
|
||||||
|
"./w:sdtPr",
|
||||||
|
asXpathNode(sdt),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (!sdtPr) {
|
||||||
|
return { type: "other", multiLine: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEl = select(
|
||||||
|
"./w:text",
|
||||||
|
asXpathNode(sdtPr),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (textEl) {
|
||||||
|
const multiLineAttr = select(
|
||||||
|
"./@w:multiLine",
|
||||||
|
asXpathNode(textEl),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlAttr | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "plain",
|
||||||
|
multiLine:
|
||||||
|
multiLineAttr?.value === "1" ||
|
||||||
|
multiLineAttr?.value === "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const richTextEl = select(
|
||||||
|
"./w:richText",
|
||||||
|
asXpathNode(sdtPr),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (richTextEl) {
|
||||||
|
return { type: "rich", multiLine: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherTypeTags = [
|
||||||
|
"w:date",
|
||||||
|
"w:dropDownList",
|
||||||
|
"w:comboBox",
|
||||||
|
"w:checkbox",
|
||||||
|
"w:citation",
|
||||||
|
"w:group",
|
||||||
|
"w:docPartObj",
|
||||||
|
"w:docPartList",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tag of otherTypeTags) {
|
||||||
|
const found = select(
|
||||||
|
`./${tag}`,
|
||||||
|
asXpathNode(sdtPr),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return { type: "other", multiLine: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "rich", multiLine: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSdtStructure(
|
||||||
|
sdtContent: XmlElement,
|
||||||
|
select: xpath.XPathSelect,
|
||||||
|
): SdtStructure {
|
||||||
|
const directP = select(
|
||||||
|
"./w:p",
|
||||||
|
asXpathNode(sdtContent),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
return directP.length > 0 ? "block" : "inline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRunChildren(
|
||||||
|
doc: XmlDocument,
|
||||||
|
text: string,
|
||||||
|
): XmlElement[] {
|
||||||
|
const nodes: XmlElement[] = [];
|
||||||
|
const tokens = text.split(/(\t|\n)/);
|
||||||
|
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
if (token === "") return;
|
||||||
|
|
||||||
|
if (token === "\t") {
|
||||||
|
nodes.push(doc.createElementNS(W_NS, "w:tab"));
|
||||||
|
} else if (token === "\n") {
|
||||||
|
nodes.push(doc.createElementNS(W_NS, "w:br"));
|
||||||
|
} else {
|
||||||
|
const t = doc.createElementNS(W_NS, "w:t");
|
||||||
|
t.setAttribute("xml:space", "preserve");
|
||||||
|
t.textContent = token;
|
||||||
|
nodes.push(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
const t = doc.createElementNS(W_NS, "w:t");
|
||||||
|
t.setAttribute("xml:space", "preserve");
|
||||||
|
t.textContent = "";
|
||||||
|
nodes.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRun(
|
||||||
|
doc: XmlDocument,
|
||||||
|
text: string,
|
||||||
|
rPr?: XmlElement,
|
||||||
|
): XmlElement {
|
||||||
|
const run = doc.createElementNS(W_NS, "w:r");
|
||||||
|
|
||||||
|
if (rPr) {
|
||||||
|
run.appendChild(rPr.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildRunChildren(doc, text).forEach((node) =>
|
||||||
|
run.appendChild(node),
|
||||||
|
);
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertInlineContent(
|
||||||
|
doc: XmlDocument,
|
||||||
|
sdtContent: XmlElement,
|
||||||
|
select: xpath.XPathSelect,
|
||||||
|
text: string,
|
||||||
|
allowLineBreak: boolean,
|
||||||
|
): void {
|
||||||
|
const safeText = allowLineBreak
|
||||||
|
? text
|
||||||
|
: text.replace(/\n/g, " ");
|
||||||
|
|
||||||
|
const existingRuns = select(
|
||||||
|
"./w:r",
|
||||||
|
asXpathNode(sdtContent),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
const firstRPr =
|
||||||
|
existingRuns.length > 0
|
||||||
|
? (select(
|
||||||
|
"./w:rPr",
|
||||||
|
asXpathNode(existingRuns[0]),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
existingRuns.forEach((r) => {
|
||||||
|
if (r.parentNode) r.parentNode.removeChild(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdtContent.appendChild(
|
||||||
|
buildRun(doc, safeText, firstRPr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBlockContent(
|
||||||
|
doc: XmlDocument,
|
||||||
|
sdtContent: XmlElement,
|
||||||
|
select: xpath.XPathSelect,
|
||||||
|
text: string,
|
||||||
|
allowMultiParagraph: boolean,
|
||||||
|
): void {
|
||||||
|
const allParagraphs = select(
|
||||||
|
"./w:p",
|
||||||
|
asXpathNode(sdtContent),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
if (allParagraphs.length === 0) {
|
||||||
|
// ป้องกัน template ที่มีโครงสร้างผิดปกติ
|
||||||
|
const p = doc.createElementNS(W_NS, "w:p");
|
||||||
|
sdtContent.appendChild(p);
|
||||||
|
allParagraphs.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstP = allParagraphs[0];
|
||||||
|
|
||||||
|
const firstPPr = select(
|
||||||
|
"./w:pPr",
|
||||||
|
asXpathNode(firstP),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
const firstRun = select(
|
||||||
|
"./w:r",
|
||||||
|
asXpathNode(firstP),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
const firstRPr = firstRun
|
||||||
|
? (select(
|
||||||
|
"./w:rPr",
|
||||||
|
asXpathNode(firstRun),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
allParagraphs.forEach((p) => {
|
||||||
|
if (p !== firstP && p.parentNode) {
|
||||||
|
p.parentNode.removeChild(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingRuns = select(
|
||||||
|
"./w:r",
|
||||||
|
asXpathNode(firstP),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
existingRuns.forEach((r) => {
|
||||||
|
if (r.parentNode) r.parentNode.removeChild(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowMultiParagraph) {
|
||||||
|
firstP.appendChild(buildRun(doc, text, firstRPr));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
text.split("\n").forEach((line, index) => {
|
||||||
|
let targetP: XmlElement;
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
targetP = firstP;
|
||||||
|
} else {
|
||||||
|
targetP = doc.createElementNS(W_NS, "w:p");
|
||||||
|
|
||||||
|
if (firstPPr) {
|
||||||
|
targetP.appendChild(firstPPr.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
sdtContent.appendChild(targetP);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = doc.createElementNS(W_NS, "w:r");
|
||||||
|
|
||||||
|
if (firstRPr) {
|
||||||
|
run.appendChild(firstRPr.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildRunChildren(doc, line).forEach((node) =>
|
||||||
|
run.appendChild(node),
|
||||||
|
);
|
||||||
|
|
||||||
|
targetP.appendChild(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertFallbackContent(
|
||||||
|
sdtContent: XmlElement,
|
||||||
|
select: xpath.XPathSelect,
|
||||||
|
text: string,
|
||||||
|
): void {
|
||||||
|
const textNodes = select(
|
||||||
|
".//w:t",
|
||||||
|
asXpathNode(sdtContent),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
if (textNodes.length === 0) return;
|
||||||
|
|
||||||
|
textNodes[0].textContent = text;
|
||||||
|
|
||||||
|
for (let i = 1; i < textNodes.length; i++) {
|
||||||
|
textNodes[i].textContent = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateData = Record<string, unknown>;
|
||||||
|
|
||||||
|
function normalizeValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
typeof value === "bigint"
|
||||||
|
) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* นำ JSON data ไปแทนค่า Content Control ใน DOCX template
|
||||||
|
* คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว
|
||||||
|
*/
|
||||||
|
export function templateContentControls(
|
||||||
|
templateBuffer: Buffer,
|
||||||
|
data: TemplateData,
|
||||||
|
): Buffer {
|
||||||
|
const zip = new PizzaZip(templateBuffer);
|
||||||
|
const docXmlFile = zip.file("word/document.xml");
|
||||||
|
|
||||||
|
if (!docXmlFile) {
|
||||||
|
throw new Error("Invalid docx file: word/document.xml not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
||||||
|
const select = createSelector();
|
||||||
|
|
||||||
|
const sdtNodes = select(
|
||||||
|
"//w:sdt",
|
||||||
|
asXpathNode(doc),
|
||||||
|
) as unknown as XmlElement[];
|
||||||
|
|
||||||
|
sdtNodes.forEach((sdt) => {
|
||||||
|
const tagAttr = select(
|
||||||
|
"./w:sdtPr/w:tag/@w:val",
|
||||||
|
asXpathNode(sdt),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlAttr | undefined;
|
||||||
|
|
||||||
|
const tagName = tagAttr?.value;
|
||||||
|
|
||||||
|
if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = normalizeValue(data[tagName]);
|
||||||
|
|
||||||
|
const sdtContent = select(
|
||||||
|
"./w:sdtContent",
|
||||||
|
asXpathNode(sdt),
|
||||||
|
true,
|
||||||
|
) as unknown as XmlElement | undefined;
|
||||||
|
|
||||||
|
if (!sdtContent) return;
|
||||||
|
|
||||||
|
const { type: controlType, multiLine } =
|
||||||
|
detectSdtControlType(sdt, select);
|
||||||
|
|
||||||
|
const structure = detectSdtStructure(sdtContent, select);
|
||||||
|
|
||||||
|
if (controlType === "other") {
|
||||||
|
insertFallbackContent(sdtContent, select, newValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (structure === "inline") {
|
||||||
|
const allowLineBreak =
|
||||||
|
controlType === "plain" ? multiLine : true;
|
||||||
|
|
||||||
|
insertInlineContent(
|
||||||
|
doc,
|
||||||
|
sdtContent,
|
||||||
|
select,
|
||||||
|
newValue,
|
||||||
|
allowLineBreak,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insertBlockContent(
|
||||||
|
doc,
|
||||||
|
sdtContent,
|
||||||
|
select,
|
||||||
|
newValue,
|
||||||
|
controlType === "rich",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
zip.file(
|
||||||
|
"word/document.xml",
|
||||||
|
serializer.serializeToString(doc),
|
||||||
|
);
|
||||||
|
|
||||||
|
return zip.generate({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
});
|
||||||
|
}
|
||||||
171
app/api/template/render/route.ts
Normal file
171
app/api/template/render/route.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
templateContentControls,
|
||||||
|
type TemplateData,
|
||||||
|
} from "../lib-tmpl";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
||||||
|
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||||
|
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||||
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const template = formData.get("template");
|
||||||
|
const dataFile = formData.get("data");
|
||||||
|
const dataJson = formData.get("dataJson");
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ตรวจสอบ Template
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
if (!(template instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Missing DOCX file in field "template".',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
template.type !== DOCX_CONTENT_TYPE &&
|
||||||
|
!template.name.toLowerCase().endsWith(".docx")
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "template" must be a .docx file.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "template" exceeds the 100MB size limit.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// อ่าน JSON
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
let data: TemplateData;
|
||||||
|
|
||||||
|
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
||||||
|
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
||||||
|
try {
|
||||||
|
if (typeof dataJson === "string" && dataJson.length > 0) {
|
||||||
|
// กรณีส่ง JSON ผ่าน form field
|
||||||
|
data = JSON.parse(dataJson) as TemplateData;
|
||||||
|
} else if (dataFile instanceof File) {
|
||||||
|
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "data" exceeds the 100MB size limit.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// กรณี upload data.json
|
||||||
|
const text = await dataFile.text();
|
||||||
|
|
||||||
|
data = JSON.parse(text) as TemplateData;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Invalid JSON data (${
|
||||||
|
error instanceof Error ? error.message : "parse error"
|
||||||
|
}).`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON ต้องเป็น object
|
||||||
|
if (
|
||||||
|
data === null ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
Array.isArray(data)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "JSON data must be an object.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Template File → Buffer
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
const templateBuffer = Buffer.from(
|
||||||
|
await template.arrayBuffer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ใช้ lib-tmpl.ts เดิม
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
const output = templateContentControls(
|
||||||
|
templateBuffer,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ส่ง DOCX กลับ
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
return new NextResponse(output as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": DOCX_CONTENT_TYPE,
|
||||||
|
|
||||||
|
"Content-Disposition":
|
||||||
|
'attachment; filename="generated-document.docx"',
|
||||||
|
|
||||||
|
"Content-Length": String(output.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Internal Server Error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/template/test/T2.json
Normal file
29
app/api/template/test/T2.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||||
|
"nottype": "(ต. ๑)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"blackyear": "๑๐/๒๕๖๙",
|
||||||
|
"redno": "คดีหมายเลขแดงที่",
|
||||||
|
"redyear": "",
|
||||||
|
"court": "ศาลปกครองสุพรรณบุรี",
|
||||||
|
"curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
|
||||||
|
"accuser": "นายธัญวิสิษฐ์ ศรแก้ว",
|
||||||
|
"acc1": "ผู้ฟ้องคดี",
|
||||||
|
"accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน",
|
||||||
|
"acc2": "ผู้ถูกฟ้องคดี",
|
||||||
|
"nottitle": "คำสั่งถึง",
|
||||||
|
"commto": "เจ้าคุณมด",
|
||||||
|
"groupname": "ผู้ถูกฟ้องคดีที่ ๑",
|
||||||
|
"detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้",
|
||||||
|
"pname": "นางนพเก้า เถื่อนชัย",
|
||||||
|
"courtname": "ศาลปกครองสุพรรณบุรี",
|
||||||
|
"courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
|
||||||
|
"courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐",
|
||||||
|
"courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
|
||||||
|
"courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”",
|
||||||
|
"sendto": "เจ้าคุณมด",
|
||||||
|
"addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -",
|
||||||
|
"remarkhead": "",
|
||||||
|
"remark": "",
|
||||||
|
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
|
||||||
|
}
|
||||||
BIN
app/api/template/test/T3.docx
Normal file
BIN
app/api/template/test/T3.docx
Normal file
Binary file not shown.
57
app/api/template/test/note.md
Normal file
57
app/api/template/test/note.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Docx Template
|
||||||
|
lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word
|
||||||
|
## วิธีการใช้ API
|
||||||
|
```sh
|
||||||
|
## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login)
|
||||||
|
http://localhost:3000/docedit/api/swagger
|
||||||
|
|
||||||
|
## template/test ไว้ทดสอบเท่านั้น
|
||||||
|
curl.exe http://localhost:3000/docedit/api/template/test
|
||||||
|
|
||||||
|
## extract ค่าออกมาจาก content control
|
||||||
|
curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx"
|
||||||
|
|
||||||
|
## render เอกสาร (docx) = template(docx)+data(json)
|
||||||
|
### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน
|
||||||
|
curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx
|
||||||
|
### data เอาจาก string
|
||||||
|
curl -X POST http://localhost:3000/docedit/api/template/render \
|
||||||
|
-F "template=@template.docx" \
|
||||||
|
-F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \
|
||||||
|
-o generated-document.docx
|
||||||
|
|
||||||
|
```
|
||||||
|
data(json) ตอนนี้อยู่ในรูปแบบ
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||||
|
"nottype": "(ต. ๑)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"blackyear": "๑๐/๒๕๖๙",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration
|
||||||
|
หลักผ่าน API
|
||||||
|
- ส่วนของ cc จะไปกรอกใน Content Control
|
||||||
|
- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร",
|
||||||
|
"uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล",
|
||||||
|
"fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน",
|
||||||
|
"ott":"อายุของ one time token แบบระยะสั้น",
|
||||||
|
"expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)",
|
||||||
|
cc:{
|
||||||
|
"notname": "คำสั่งเรียก ให้ทำคำให้การ",
|
||||||
|
"nottype": "(ต. ๑)",
|
||||||
|
"blackno": "คดีหมายเลขดำที่",
|
||||||
|
"blackyear": "๑๐/๒๕๖๙",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
ที่ต้องทำเพิ่ม
|
||||||
|
- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต
|
||||||
|
- Backend จัดการ version
|
||||||
|
- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน
|
||||||
BIN
app/api/template/test/output-T3.docx
Normal file
BIN
app/api/template/test/output-T3.docx
Normal file
Binary file not shown.
BIN
app/api/template/test/output.docx
Normal file
BIN
app/api/template/test/output.docx
Normal file
Binary file not shown.
10
app/api/template/test/route.ts
Normal file
10
app/api/template/test/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
service: "docx-template-api",
|
||||||
|
});
|
||||||
|
}
|
||||||
43
app/api/template/test/test.http
Normal file
43
app/api/template/test/test.http
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
@baseUrl = http://localhost:3000/docedit/api
|
||||||
|
|
||||||
|
### 1. Extract Template
|
||||||
|
POST {{baseUrl}}/template/extract
|
||||||
|
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||||
|
|
||||||
|
--WebAppBoundary
|
||||||
|
Content-Disposition: form-data; name="file"; filename="output-T3.docx"
|
||||||
|
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
|
||||||
|
< ./output-T3.docx
|
||||||
|
--WebAppBoundary--
|
||||||
|
|
||||||
|
### 2. Render Template with JSON File
|
||||||
|
POST {{baseUrl}}/template/render
|
||||||
|
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||||
|
|
||||||
|
--WebAppBoundary
|
||||||
|
Content-Disposition: form-data; name="template"; filename="T3.docx"
|
||||||
|
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
|
||||||
|
< ./T3.docx
|
||||||
|
--WebAppBoundary
|
||||||
|
Content-Disposition: form-data; name="data"; filename="T2.json"
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
< ./T2.json
|
||||||
|
--WebAppBoundary--
|
||||||
|
|
||||||
|
### 3. Render Template with Raw JSON String
|
||||||
|
POST {{baseUrl}}/template/render
|
||||||
|
Content-Type: multipart/form-data; boundary=WebAppBoundary
|
||||||
|
|
||||||
|
--WebAppBoundary
|
||||||
|
Content-Disposition: form-data; name="template"; filename="template.docx"
|
||||||
|
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
|
||||||
|
< ./T3.docx
|
||||||
|
--WebAppBoundary
|
||||||
|
Content-Disposition: form-data; name="dataJson"
|
||||||
|
|
||||||
|
{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"}
|
||||||
|
--WebAppBoundary--
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
FROM node:24-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
# postinstall รัน scripts/patch-pdf-worker.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
# postinstall รัน scripts/patch-pdf-worker.mjs + scripts/copy-swagger-ui.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||||
COPY scripts/patch-pdf-worker.mjs scripts/
|
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/
|
||||||
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
|
@ -15,7 +15,8 @@ COPY . .
|
||||||
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
||||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||||
RUN node scripts/patch-pdf-worker.mjs
|
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
|
||||||
|
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||||
|
|
|
||||||
412
lib/openapi.ts
Normal file
412
lib/openapi.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
||||||
|
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API
|
||||||
|
// 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)",
|
||||||
|
},
|
||||||
|
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: "template",
|
||||||
|
description:
|
||||||
|
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
|
||||||
|
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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)",
|
||||||
|
example:
|
||||||
|
'{"notname":"คำสั่งเรียก ให้ทำคำให้การ","blackno":"คดีหมายเลขดำที่","blackyear":"๑๐/๒๕๖๙"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
@ -21,12 +21,15 @@ export function middleware(req: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), ไฟล์อัปโหลด, Next internal,
|
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||||
// และ static ใน public/note/ (pdf.worker, samples) + public/ ราก (fonts, favicon)
|
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
||||||
|
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||||
|
// ไฟล์อัปโหลด, Next internal, และ static ใน public/note/ (pdf.worker, samples,
|
||||||
|
// asset Swagger UI) + public/ ราก (fonts, favicon)
|
||||||
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
||||||
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!note/login|api/note|note/uploads|note/samples|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
|
"/((?!note/login|api/note|api/template|api/swagger|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
65
package-lock.json
generated
65
package-lock.json
generated
|
|
@ -10,16 +10,20 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
|
"@xmldom/xmldom": "^0.9.12",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.42.0",
|
"lucide-react": "^1.42.0",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"pizzip": "^3.3.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"utif2": "^4.1.0"
|
"swagger-ui-dist": "^5.33.1",
|
||||||
|
"utif2": "^4.1.0",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.5.0",
|
||||||
|
|
@ -1933,6 +1937,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@scarf/scarf": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
|
@ -2061,6 +2072,15 @@
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
|
||||||
|
"integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
|
|
@ -4023,6 +4043,31 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pizzip": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pizzip/-/pizzip-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-seR2ALUYS4Z0GCs0sVwXWImhBsoCImXpHr+ExNfIZ9G0czvHqpUxlJgwHCl1BBHFyU3nUFmZJPFEEWbpY8cKRg==",
|
||||||
|
"license": "(MIT OR GPL-3.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pizzip/node_modules/pako": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
|
@ -4539,6 +4584,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swagger-ui-dist": {
|
||||||
|
"version": "5.33.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz",
|
||||||
|
"integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@scarf/scarf": "=1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|
@ -5113,6 +5167,15 @@
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xpath": {
|
||||||
|
"version": "0.0.34",
|
||||||
|
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
|
||||||
|
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,24 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||||
"postinstall": "node scripts/patch-pdf-worker.mjs"
|
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
|
"@xmldom/xmldom": "^0.9.12",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.42.0",
|
"lucide-react": "^1.42.0",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"pizzip": "^3.3.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"utif2": "^4.1.0"
|
"swagger-ui-dist": "^5.33.1",
|
||||||
|
"utif2": "^4.1.0",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.5.0",
|
||||||
|
|
|
||||||
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
public/note/swagger-ui/swagger-ui.css
Normal file
3
public/note/swagger-ui/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
30
scripts/copy-swagger-ui.mjs
Normal file
30
scripts/copy-swagger-ui.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// คัดลอก asset ของ Swagger UI (swagger-ui-dist) จาก node_modules ไป public/
|
||||||
|
// ให้ route handler GET /api/template อ้าง URL ได้ (/docedit/note/swagger-ui/…)
|
||||||
|
// — ระบบใช้ใน intranet จึง self-host ทุกอย่าง (ไม่โหลดจาก CDN เหมือนเรื่องฟอนต์)
|
||||||
|
//
|
||||||
|
// ไฟล์ที่ต้องมีครบ 2 ตัว: bundle (JS) + CSS — หายตัวไหน (เช่นเปลี่ยนชื่อหลัง
|
||||||
|
// อัปเกรด swagger-ui-dist) = exit 1 ดัง ๆ ทันที ไม่ปล่อยให้หน้า Swagger UI
|
||||||
|
// เป็นหน้าเปล่าเงียบ ๆ (pattern เดียวกับ scripts/patch-pdf-worker.mjs)
|
||||||
|
import { copyFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… — basePath /docedit + route prefix /note)
|
||||||
|
const FILES = [
|
||||||
|
["node_modules/swagger-ui-dist/swagger-ui-bundle.js", "public/note/swagger-ui/swagger-ui-bundle.js"],
|
||||||
|
["node_modules/swagger-ui-dist/swagger-ui.css", "public/note/swagger-ui/swagger-ui.css"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [src, dest] of FILES) {
|
||||||
|
try {
|
||||||
|
// Dockerfile mkdir แค่ public/ เปล่า ๆ — script ต้องสร้าง dir ปลายทางเอง
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
`[copy-swagger-ui] คัดลอก ${src} ไม่ได้ — ` +
|
||||||
|
`เช็คว่า swagger-ui-dist ติดตั้งแล้ว/ชื่อไฟล์ยังเหมือนเดิมหลังอัปเกรด (ปรับ FILES ใน scripts/copy-swagger-ui.mjs)`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[copy-swagger-ui] คัดลอก Swagger UI asset เรียบร้อย");
|
||||||
Loading…
Add table
Add a link
Reference in a new issue