Compare commits

...

24 commits
v1.0.23 ... dev

Author SHA1 Message Date
oom
a3d14fd58d Add generate API, Prototype for developer, Clean data
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 1m11s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 0s
2026-10-27 01:47:08 +07:00
Suphonchai Phoonsawat
6ce071ee14 fix(token): restore environment variable checks for DOCVER_API_KEY and DOCVER_TOKEN_SECRET
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m16s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
2026-10-26 18:37:02 +07:00
Suphonchai Phoonsawat
ab62a2b3e2 feat(docker): add MinIO configuration and token settings in Dockerfile
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 1m6s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
2026-10-26 13:02:25 +07:00
Suphonchai Phoonsawat
c06da83f92 fix(docver): update token secret handling to use hardcoded value for testing
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 1m6s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 3s
2026-10-26 12:37:29 +07:00
Suphonchai Phoonsawat
86353cbe57 refactor(docver): improve code readability and structure in token handling functions
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 1m13s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
2026-10-26 12:29:08 +07:00
Suphonchai Phoonsawat
0b2c9e6343 feat(docver): derive docId from fid claim and drop [docId] URL segment
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 1m9s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- docId of every docver operation now comes from the Bearer token: the fid
  claim (e-Court fileId) is used directly as docId; segment [docId] removed
  from URLs — POST/GET /api/documents/versions and
  GET /api/documents/versions/{versionId}/download-link
- fid is required and must be a string (trim 1-200): token issuance rejects
  missing/blank/non-string fid with 400, verify rejects fid-less tokens with 401
- parseDocId removed from lib/document-versioning.ts (dead code, no callers)
- openapi spec: paths renamed, docId path params removed, fid required + string
- PLAN-DOCVER.md / CLAUDE.md / wordaddin versioningService comment updated
- tests: fid required/string cases added, parseDocId describe removed (256 pass)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 10:05:01 +07:00
Suphonchai Phoonsawat
aad6f155fe Merge branch 'dev' into working 2026-10-26 09:02:13 +07:00
Suphonchai Phoonsawat
98b370c925 feat(docver): add access token endpoint and Bearer auth on all endpoints
- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401)
- all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue
- identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400)
- lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes
- openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated
- tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed)
- package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 06:29:19 +07:00
oom
ac6c0dc71e Add new template format with hashing algorithm
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m18s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 6s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
2026-10-22 22:33:14 +07:00
Suphonchai Phoonsawat
280cc2da40 Merge branch 'dev' into feature/versionning 2026-10-20 11:03:12 +07:00
Suphonchai Phoonsawat
949861ba86 Merge branch 'dev' of https://forgejo.chamomind.com/chamomind/eCourt-note into dev 2026-10-20 11:03:02 +07:00
Suphonchai Phoonsawat
ed8493004c Merge branch 'dev' of https://forgejo.chamomind.com/chamomind/eCourt-note into feature/versionning
# Conflicts:
#	CLAUDE.md
2026-10-20 11:02:02 +07:00
Suphonchai Phoonsawat
765946f0d5 feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).

- POST versions (multipart): validates fields/magic bytes/100MB cap,
  uploads to MinIO first, cleans up objects if the DB insert fails;
  returns {hasPdf, versionId, versionNumber} with server-authoritative
  numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
  (docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
  host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)

wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
Your Name
6daee15851 feat(wordaddin): add template tools tab
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 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
แท็บที่สองของ taskpane "เครื่องมือ Template" (TemplateToolsPanel +
templateService — Office.js ล้วน ไม่ยิง backend ใช้ได้ทั้ง phase
untracked/tracking) สำหรับคนออกแบบ template:

- สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + ปุ่มดูช่อง
  (select เลื่อน Word ไปให้เอง)
- กรอกค่าทดสอบเขียนกลับเอกสารจริงเพื่อดูผลจัดหน้า — ช่องว่างไม่ถูกแตะ,
  เขียนแยก Word.run ราย tag ให้ fail เฉพาะตัวพร้อมสาเหตุจาก debugInfo
- แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor — page break มี
  fallback OOXML block-level กัน E_FAIL ในบางบริบท (เช่น cursor ในตาราง)
- มองเห็น CC ทุกชนิดผ่าน feature-detect WordApi 1.5
  (Document.getContentControls) — Office 2019 เห็นเฉพาะ rich text พร้อม
  warning แยก state ไม่โดน banner อื่นล้าง, ห้ามยก MinVersion ของ manifest

จาก code review 2026-10-20:

- ปุ่ม toggle แท็บปิดระหว่าง flow ที่เขียนเอกสารครบทุกตัว
  (saving/starting/saveBusy/templateBusy — panel รายงาน busy ผ่าน
  onBusyChange พร้อม cleanup ตอน unmount) กัน flow versioning มา
  stamp/capture ซ้อน operation ของแท็บ template ที่ยังรันอยู่
- banner auto-save error ยกจาก SaveControls มาไว้เหนือ toggle
  ให้เห็นทั้งสองแท็บ
- สแกน merge หลังเขียนค่าตัด tag ที่หายไปจากเอกสารทิ้ง (ไม่ค้าง
  ไปโดน missingTags รอบถัดไป)
- รวม describeWordError เป็น getDetailedErrorMessage ที่ errors.ts

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:49:17 +07:00
Suphonchai Phoonsawat
5917c0532c file 2026-10-20 09:35:25 +07:00
Your Name
a172b111a3 docs(wordaddin): sideload dev ชี้ trusted catalog ที่ public/wordaddin
เครื่อง dev ที่มี repo ไม่ต้องดาวน์โหลด/คัดลอก manifest แยกแล้ว —
ชี้ Trusted Add-in Catalogs ไปที่ public/wordaddin ตรง ๆ:
manifest.template.xml คือ dev manifest อยู่แล้ว (URL localhost)
และเนื้อไฟล์อัปเดตตาม branch ที่ checkout เสมอ

ห้ามวาง xml อื่นเพิ่มในโฟลเดอร์นี้ — Word สแกน *.xml ทุกไฟล์
GUID เดียวกันสองไฟล์ทำให้ Word สับสน

เครื่องที่ไม่มี repo (production/ผู้ใช้) ยังใช้ flow ดาวน์โหลด
จาก URL ของ environment นั้นแล้ววางโฟลเดอร์แยกตามเดิม

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 14:48:04 +07:00
Your Name
1aeaae38f1 fix(wordaddin): lower WordApi requirement for Office 2019
add-in โหลดไม่ขึ้นเลยบนเครื่องศาลที่ใช้ Office 2019 — manifest
ตั้ง WordApi 1.5 ซึ่งเป็น Microsoft 365 เท่านั้น (Office 2019
perpetual รองรับสูงสุด 1.3) host ที่ไม่ถึง requirement จะปฏิเสธ
โหลด add-in ตั้งแต่ต้นตามกลไกของ Office

แก้: รองลงเป็น WordApi 1.3 (customXmlParts คือ API สูงสุดที่ใช้
ประจำ — customProperties/save/contentControls เป็น 1.1)
+ bump Version 1.0.0.1 → 1.0.0.2 ให้ Word ทุกเครื่องเห็นว่าเป็น
เวอร์ชันใหม่แล้วดึง manifest ใหม่ (กันแค manifest เดิมของตัว
poc ที่เคย sideload — GUID เดิม)

ผลข้างเคียงบน Office 2019: ปุ่ม "บันทึกและปิดเอกสาร" /
"บันทึกเวอร์ชันและปิดเอกสาร" ถูกซ่อนอัตโนมัติ — document.close()
ต้องการ WordApi 1.4/1.5 โดย canCloseDocument() เช็ค
isSetSupported("WordApi","1.5") ตอน runtime อยู่แล้ว (กลไกเดิม
ที่ทำไว้สำหรับ Word on the web) ฟีเจอร์อื่นใช้ได้ครบ

ยืนยัน: parse XML ผ่าน (@xmldom) — WordApi=1.3,
OpenBrowserWindowApi=1.1 (Office 2019 รองรับ), Version=1.0.0.2 ·
สำรวจ API ที่ใช้จริงทั้ง wordaddin/services แล้วทุกตัว ≤1.3
ยกเว้น close() ที่มี runtime guard

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 14:20:24 +07:00
Your Name
4bcd01a95f feat(wordaddin): serve manifest with automatic origin rewrite
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 1m7s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
manifest ของ add-in เดิมเป็น static ใน public/ ฝัง URL dev
(localhost:3000) ทั้งไฟล์ — deploy จริงต้อง rewrite มือเองทุกครั้ง
ก่อนนำไป sideload ใน Word

ตอนนี้ serve ผ่าน route แทน: app/wordaddin/manifest.xml/route.ts
อ่าน template (public/wordaddin/manifest.template.xml — ย้ายจาก
manifest.xml ด้วย git mv) แล้วเขียนทับ origin ให้ตรงโฮสต์ที่ขอ
ไฟล์ — dev/UAT/production ดาวน์โหลด manifest.xml ขอ environment
ตัวเองแล้ว sideload ได้ทันที

- ถอด requestOrigin จาก N10 route เป็น utils/request-origin.ts
  ใช้ร่วม + เพิ่ม heuristic: โฮสต์ไม่ใช่ localhost + proto http =
  บังคับ https (Office รับ https เท่านั้น และ hop ภายในหลัง IIS
  เป็น http เสมอ — Next server ก็ฉีด x-forwarded-proto เอง จึง
  ชี้ขาดจากโฮสต์ปลายทาง ไม่เดาว่า header มาจาก proxy หรือถูกฉีด)
  deep link ของ N10 ได้พฤติกรรมเดียวกันเป็นผลพวง
- ตรวจ nft trace แล้ว template ถูกดึงเข้า standalone ด้วย (คู่
  กับ COPY public จาก commit ก่อน = ชัวร์สองชั้น)

ยืนยัน: tsc + vitest 215 tests ผ่าน · ทดสอบบน dev server ด้วย curl
3 โหมด — direct ได้ http://localhost:3000 (Word ยกเว้น localhost),
ผ่าน proxy ส่ง header ครบได้ https://<โฮสต์>, ผ่าน proxy ไม่ส่ง
proto (กรณี IIS) ได้ https://<โฮสต์> จากการ bump · next build
compile route ผ่าน (ขั้น standalone symlink พังเฉพาะเครื่อง local
Windows สิทธิ์ symlink — ไม่เกี่ยวกับโค้ด Docker build ผ่าน)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 13:54:25 +07:00
Your Name
808233191d fix(docker): copy public/ into production image
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m0s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
taskpane wordaddin พังใน production — "โหลด Office.js ไม่สำเร็จ"
เพราะ browser ยิง /docedit/wordaddin/office-js/office.js ได้ 404:
image ไม่มี public/wordaddin อยู่เลย

สาเหตุ: Next 14.2 standalone ไม่ copy public/ เอง (comment เดิมใน
Dockerfile เข้าใจผิด) — ไฟล์ static ที่เคยใช้ได้หลุดเข้า image แบบ
บังเอิญผ่าน output file tracing ที่ตาม fs.readFile ใน server code
(public/note จาก lib/server-pdf.ts, ฟอนต์ Sarabun จาก export route)
แต่ public/wordaddin/** ไม่มี server code ไหนอ่านผ่าน fs จึงไม่ถูก
ดึงมา

แก้: เพิ่ม COPY --from=builder /app/public ./public ใน runner stage
ตามเอกสาร deploy ของ Next + แก้ comment Dockerfile และ
docs/WORDADDIN-DEV.md ให้เล่ากลไกจริง

ยืนยันด้วยการ build image ใหม่แล้วรัน container จริง:
office.js / manifest.xml / word-win32-16.01.js / th-th strings /
pdf.worker ทุก URL ตอบ 200

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 13:28:04 +07:00
Your Name
2b70a50c62 feat(wordaddin): add e-Court pass-through sign-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m8s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 6s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
ไฟล์ที่ e-Court stamp custom properties token/owner มาใช้งานได้เลย
ไม่ต้องกด login — detect() ของ TaskpaneApp อ่านค่าทั้งสองตอนเปิด
task pane แล้วเรียก signInFromDocument(): username จาก owner →
uid ใน JWT payload → NEXT_PUBLIC_ADDIN_MOCK_USERNAME, token จาก
ไฟล์เก็บใน memory ให้ getAccessToken() ใช้ต่อ

decodeJwtField ถอด base64url เป็น bytes แล้วผ่าน TextDecoder ก่อน
JSON.parse — uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) ไม่เป็น mojibake

อัปเดตเอกสาร CLAUDE.md + docs/WORDADDIN-DEV.md พร้อมวิธีทดสอบ
เองใน Word ผ่าน Advanced Properties

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 13:06:28 +07:00
Your Name
dc851fbb73 chore: add pnpm lockfile 2026-10-16 12:11:12 +07:00
Your Name
33670d0b18 chore(template): add manual test fixture Test.docx 2026-10-16 12:11:03 +07:00
Your Name
c401f4deb7 feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก
D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin:

- wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด
  (authService/versioningService เป็น mock รอ backend จริง) + models + config
- components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton
  กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ)
  และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system
- scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js
  runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore
  สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close)
- middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก
  แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8
  (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม
- docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts
  dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 12:07:25 +07:00
Your Name
7a146d48be fix(config): move root redirect to page and skip slash redirect
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 50s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
- เข้า /docedit เดิมใช้ redirects() (Next เติม basePath ให้ source /)
  ถอดออกให้ app/page.tsx ที่ root จับ /docedit และ /docedit/ แทน
  พร้อมส่งต่อ query เอง เพราะ redirect() ของ next/navigation ไม่แนบ
  searchParams ให้อัตโนมัติ (รักษาสัญญาลิงก์ e-Court
  /docedit/?fileUrl=&fileId=&typeId= ของ N8)
- เพิ่ม skipTrailingSlashRedirect: true ปิด 308 trailing slash
  อัตโนมัติของ Next กัน ping-pong กับ rewrite rule ของ IIS ตอน
  deploy หลัง proxy
- redirects() ใหม่แบบ basePath:false จับเฉพาะ root ของ host (/ แท้)
  ส่งตรงไป /docedit/note

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 11:11:48 +07:00
82 changed files with 25642 additions and 1199 deletions

View file

@ -5,3 +5,25 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY=
# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ —
# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=19000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# MINIO_BUCKET=docver
# DOCVER_LINK_TTL_SECONDS=900
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
DOCVER_API_KEY=
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
DOCVER_TOKEN_SECRET=
# DOCVER_TOKEN_TTL_SECONDS=3600

9
.gitignore vendored
View file

@ -31,5 +31,14 @@ next-env.d.ts
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
public/note/uploads/
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
public/wordaddin/office-js/
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
/certs/
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
docker-compose.test.yml
certificates

View file

@ -12,6 +12,7 @@
- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`)
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6)
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — ปัจจุบัน auth + API เป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้)
- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น)
- mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
@ -20,7 +21,7 @@
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`)
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath`
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)`
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
@ -28,16 +29,19 @@
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน)
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
## โครงสร้างไฟล์ (ตามแผน)
```
docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket)
docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 4 endpoint (เพิ่ม POST /token ขอ access_token — Bearer บังคับทุก endpoint), ดีไซน์ตาราง, env MINIO_* + DOCVER_API_KEY/DOCVER_TOKEN_SECRET/DOCVER_TOKEN_TTL_SECONDS ทั้งหมด
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)
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
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/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, 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/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
@ -45,7 +49,18 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย
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.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 ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param)
lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid)
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
<<<<<<< HEAD
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
=======
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank)
>>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
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 ใช้ร่วม)
@ -68,12 +83,17 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
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/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath
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/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/login/page.tsx ← หน้า login (มี session แล้ว redirect /note)
app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client)
app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น)
components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง)
components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)**
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)

View file

@ -19,7 +19,7 @@
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|---|---|---|
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` |
## ขั้นตอน Setup จากศูนย์
@ -31,17 +31,16 @@ npm install
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
### 2. รัน PostgreSQL container
### 2. รัน PostgreSQL + MinIO ด้วย docker compose
```bash
docker run -d --name judes-note-db \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
-p 54322:5432 \
-v judes-note-db-data:/var/lib/postgresql/data \
postgres:17-alpine
docker compose up -d
```
ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio`
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
### 3. สร้างไฟล์ `.env`
@ -50,6 +49,12 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY=
# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2)
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=19000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
```
(เทียบกับ `.env.example`)
@ -125,6 +130,28 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
"http://localhost:3000/docedit/api/note/external/documents"
```
## API จัด version เอกสาร (docver — stand-alone)
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
```bash
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
```
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
## Troubleshooting
| อาการ | สาเหตุ/ทางแก้ |
@ -132,7 +159,8 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
## หมายเหตุทางเทคนิค
@ -148,11 +176,14 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
db/index.ts postgres.js + drizzle (prepare: false)
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
utils/user.ts parse/build cookie oacCase (mock login e-Court)
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar

View file

@ -0,0 +1,51 @@
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่
// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
import { docverError } from "@/lib/document-versioning";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocverApiKey,
} from "@/lib/docver-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail;
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseTokenRequestBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
const ttlSeconds =
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds);
return new Response(
JSON.stringify({
access_token: accessToken,
token_type: "Bearer",
expires_in: ttlSeconds,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}

View file

@ -0,0 +1,52 @@
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
import {
docverError,
parseDownloadFormat,
parseVersionIdParam,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { versionId: string } },
) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
const docId = auth.claims.fid;
const versionId = parseVersionIdParam(params.versionId);
if (!versionId.ok) return docverError(versionId.error, 400);
let format: string | null = null;
try {
format = new URL(request.url).searchParams.get("format");
} catch {
return docverError("URL ของ request ไม่ถูกต้อง", 400);
}
const parsedFormat = parseDownloadFormat(format);
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
try {
const row = await findVersionForDoc(docId, versionId.versionId);
if (!row) {
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
}
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
return docverError("version นี้ไม่มีสำเนา PDF", 404);
}
const link = await presignVersionUrl(row.id, parsedFormat.format);
return new Response(JSON.stringify(link), {
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("docver: สร้าง download link ล้ม", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -0,0 +1,170 @@
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
// Authorization: Bearer ทุก request — identity (userid/username), templateId และ
// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
import { createHash, randomUUID } from "node:crypto";
import { toDocumentVersionDTO } from "@/db/schema";
import {
docverError,
DOCVER_MAX_UPLOAD_BYTES,
DOCX_CONTENT_TYPE,
isDocxBytes,
isPdfBytes,
parseVersionUploadForm,
sanitizeFilePartName,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import {
findDocumentByDocId,
insertVersionRecord,
listVersionsByDocId,
putVersionObjects,
removeVersionObjects,
versionObjectKeys,
} from "@/lib/docver-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
export async function POST(request: Request) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
const docId = auth.claims.fid;
let form: FormData;
try {
form = await request.formData();
} catch {
return docverError("body ต้องเป็น multipart/form-data", 400);
}
const parsed = parseVersionUploadForm(form);
if (!parsed.ok) return docverError(parsed.error, 400);
const file = form.get("file");
if (!(file instanceof File)) {
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
}
if (file.size === 0) {
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
}
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
}
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
}
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
const rawPdf = form.get("pdf");
let pdf: File | null = null;
if (rawPdf instanceof File && rawPdf.size > 0) {
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
}
pdf = rawPdf;
}
let docxBytes: Buffer;
let pdfBytes: Buffer | null = null;
try {
docxBytes = Buffer.from(await file.arrayBuffer());
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
} catch (err) {
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
if (!isDocxBytes(docxBytes)) {
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
}
if (pdfBytes && !isPdfBytes(pdfBytes)) {
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
}
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
const versionId = randomUUID();
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
const docxDisplayName =
parsed.form.fileName ?? sanitizeFilePartName(file.name);
try {
await putVersionObjects({
versionId,
docx: { bytes: docxBytes, fileName: docxDisplayName },
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
});
} catch (err) {
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
const keys = versionObjectKeys(versionId);
let row;
try {
row = await insertVersionRecord({
versionId,
docId,
clientVersionId: parsed.form.clientVersionId,
contentHash,
message: parsed.form.message,
fields: parsed.form.fields,
templateId: auth.claims.template,
fileName: docxDisplayName,
docxObjectKey: keys.docx,
pdfObjectKey: pdfBytes ? keys.pdf : null,
docxSize: docxBytes.length,
pdfSize: pdfBytes?.length ?? null,
userId: auth.claims.userid,
userName: auth.claims.username,
});
} catch (err) {
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
await removeVersionObjects(versionId, pdfBytes !== null);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
return new Response(
JSON.stringify({
hasPdf: pdfBytes !== null,
versionId: row.id,
versionNumber: row.versionNumber,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
export async function GET(request: Request) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
const docId = auth.claims.fid;
try {
const doc = await findDocumentByDocId(docId);
if (!doc) {
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
}
const rows = await listVersionsByDocId(doc.id);
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("docver: โหลด version history ล้ม", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -4,6 +4,7 @@ import { db } from "@/db";
import { files } from "@/db/schema";
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
import { requestOrigin } from "@/utils/request-origin";
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
* x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย
* fallback จาก host/protocol ของ request ตรง ๆ */
function requestOrigin(req: Request): string {
const url = new URL(req.url);
const proto =
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
url.protocol.replace(":", "");
const host =
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
req.headers.get("host") ??
url.host;
return `${proto}://${host}`;
}
// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม
// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16)
export async function POST(req: Request) {
const unauthorized = requireExternalApiKey(req);

View file

@ -0,0 +1,108 @@
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??"";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??"";
const TMPL_PATH = process.env.TMPL_PATH??"";
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
}
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
{
// console.log("processRequest get:",fid,tid,uid,uname)
// console.log(process.cwd())
const filePath = path.join(TMPL_PATH,`${fid}.json`);
try {
if (!fid||!tid||!uid||!uname) {
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
}
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
const rawJson = await fs.readFile(filePath, 'utf-8');
const jsonData = JSON.parse(rawJson);
if (!jsonData || typeof jsonData !== 'object' ) {
return NextResponse.json({error:'Invalid data format'}, { status: 400 });
}
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!!
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
if(!Object.hasOwn(jsonData, 'info'))
jsonData['info']={}
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname})
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
//ยิงไปหาระบบ Docedit เพื่อเอา Stream
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': EXTERNAL_API_KEY,
},
body: JSON.stringify(jsonData),
});
// เช็ค Content-Type และ Status ก่อน
const contentType = docxResponse.headers.get('content-type') || '';
if (!docxResponse.ok || contentType.includes('text/html')) {
const errorText = await docxResponse.text();
console.error('download-docx:Invalid docx Response:', errorText);
return NextResponse.json(
{error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`},
{ status: 502 }
);
}
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
const stream = docxResponse.body;
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
const download_file = `${fid}_${uid}_xxx`
return new NextResponse(stream as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
'Cache-Control': 'no-store, max-age=0',
},
});
} catch (error:any) {
if ( error.code === 'ENOENT') {
return NextResponse.json(
{ error: `Data not found: ${filePath}`},
{ status: 404 }
);
}
console.error(error)
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
}
}
export async function GET(request: NextRequest)
{
const searchParams = request.nextUrl.searchParams;
const fid = searchParams.get('fid') || '';
const tid = searchParams.get('tid') || '';
const uid = searchParams.get('uid') || '';
const uname = searchParams.get('uname') || '';
return await processRequest(fid,tid,uid,uname)
}
export async function POST(request: NextRequest) {
const formData = await request.formData();
const fid = String(formData.get('fid'))
const tid = String(formData.get('tid'))
const uid = String(formData.get('uid'))
const uname = String(formData.get('uname'))
return await processRequest(fid,tid,uid,uname)
}

View file

@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { extractContentControls } from "../lib-tmpl";
export const runtime = "nodejs";
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
// export const runtime = "nodejs";
const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
@ -14,24 +14,14 @@ export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const TEST_SECRET = String(formData.get("secret") ?? "");
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
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")
if (!(file instanceof File) ||
(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 },
@ -41,7 +31,6 @@ export async function POST(request: Request) {
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
success: false,
error: 'Field "file" exceeds the 100MB size limit.',
},
{ status: 400 },
@ -52,18 +41,36 @@ export async function POST(request: Request) {
const buffer = Buffer.from(await file.arrayBuffer());
// ใช้ lib-tmpl.ts เดิม
const data = extractContentControls(buffer);
// const data = extractContentControls(buffer);
return NextResponse.json({
success: true,
data,
});
const cc = extractContentControls(buffer);
const bookmark = extractBookmark(buffer)
const properties = getDocProps(buffer)
let verify: Record<string, unknown> ={}
const ticket_data = properties?.custom?.ticket_data
const HmacSha256 = String(properties?.custom?.HmacSha256)
const Ed25519 = String(properties?.custom?.Ed25519)
if(ticket_data && typeof ticket_data === 'string'){
const ticketDataObj= JSON.parse(ticket_data)
const unix_timestamp = Number(ticketDataObj.exp)*1000
if(unix_timestamp){
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
}
if(TEST_SECRET && HmacSha256){
verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
}
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
}
}
let returnData : Record<string, unknown> = {cc,bookmark,properties,verify}
return NextResponse.json(returnData);
} catch (error) {
console.error(error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message

View file

@ -0,0 +1,101 @@
// สร้าง docx จาก json
// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้
// - สร้าง signature/hash จาก json.info.ticket_data
import { NextRequest, NextResponse } from 'next/server';
import {readFile} from 'fs/promises';
import path from 'path';
import { applyTemplate } from '../render/route'
import { DocumentTemplate, templateContentControls, setCustomDocProps, templateDynamicContents } from "@/lib/lib-tmpl";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
const TMPL_PATH = process.env.TMPL_PATH || ''
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
const SECRET = process.env.SECRET || ''
if (EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
export async function POST(request: NextRequest) {
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
const apiKey = request.headers.get('x-api-key');
if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
return NextResponse.json(
{ error: 'Unauthorized: Invalid API Key' },
{ status: 401 }
);
}
try {
// รับข้อมูล JSON Payload
const templateData:DocumentTemplate = (await request.json());
console.log(templateData)
// ต้องมี templateData.info.ticket_data เป็น json string เสมอ
if (!templateData || !templateData.info ||
!templateData.info.ticket_data ||
typeof templateData.info.ticket_data !== "string"
) {
return NextResponse.json(
{
error: "template/generate json.info.ticket_data not avaliable",
},
{ status: 400 },
);
}
const ticketObj = JSON.parse(templateData.info.ticket_data)
const tid = String(ticketObj.tid)
const fid = String(ticketObj.fid)
const uid = String(ticketObj.uid)
if(!tid||!fid||!uid)
{
return NextResponse.json(
{
error: "template/generate require json.info.ticket_data.(fid|tid|uid)",
},
{ status: 400 },
);
}
//เปิดไฟล์ template
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`);
let templateBuffer: Buffer = await readFile(tmplPath)
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX);
// สร้าง ReadableStream ของไฟล์ .docx
// ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System
// const encoder = new TextEncoder();
const docxStream = new ReadableStream({
start(controller) {
controller.enqueue(output);
controller.close();
},
});
// 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word
const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`)
return new NextResponse(docxStream, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${dlFileName}.docx"`,
'Cache-Control': 'no-store, max-age=0',
},
});
} catch (error:any) {
if ( error.code === 'ENOENT') {
return NextResponse.json(
{ error: 'NotFound: Invalid template ID or incorrect storage path'},
{ status: 404 }
);
}
console.error('Error generating docx:', error);
return NextResponse.json(
{ error: 'Failed to generate document payload'},
{ status: 500 }
);
}
}

View file

@ -1,518 +0,0 @@
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",
});
}

View file

@ -1,94 +1,71 @@
import { NextResponse } from "next/server";
import {
templateContentControls,
type TemplateData,
} from "../lib-tmpl";
import { DocumentTemplate, templateContentControls, setCustomDocProps, templateDynamicContents } from "@/lib/lib-tmpl";
import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils';
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
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
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");
const TEST_SECRET = String(formData.get("secret")||"")
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
// -----------------------------
// ตรวจสอบ 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")
if (template.type !== DOCX_CONTENT_TYPE &&
!template.name.toLowerCase().endsWith(".docx") &&
template.size > MAX_UPLOAD_BYTES
) {
return NextResponse.json(
{
success: false,
error: 'Field "template" must be a .docx file.',
error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
},
{ 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;
let templateData: DocumentTemplate;
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
try {
if (typeof dataJson === "string" && dataJson.length > 0) {
// กรณีส่ง JSON ผ่าน form field
data = JSON.parse(dataJson) as TemplateData;
templateData = JSON.parse(dataJson) as DocumentTemplate;
} 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;
templateData = JSON.parse(text) as DocumentTemplate;
} else {
return NextResponse.json(
{
success: false,
error:
'Missing JSON data. Send either "data" file or "dataJson" field.',
},
@ -98,24 +75,20 @@ export async function POST(request: Request) {
} catch (error) {
return NextResponse.json(
{
success: false,
error: `Invalid JSON data (${
error instanceof Error ? error.message : "parse error"
}).`,
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
}).`,
},
{ status: 400 },
);
}
// JSON ต้องเป็น object
if (
data === null ||
typeof data !== "object" ||
Array.isArray(data)
templateData === null ||
typeof templateData !== "object" ||
Array.isArray(templateData)
) {
return NextResponse.json(
{
success: false,
error: "JSON data must be an object.",
},
{ status: 400 },
@ -126,31 +99,21 @@ export async function POST(request: Request) {
// Template File → Buffer
// -----------------------------
const templateBuffer = Buffer.from(
let templateBuffer: Buffer = Buffer.from(
await template.arrayBuffer(),
);
// -----------------------------
// ใช้ lib-tmpl.ts เดิม
// -----------------------------
const output = templateContentControls(
templateBuffer,
data,
);
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX);
// -----------------------------
// ส่ง DOCX กลับ
// -----------------------------
if(templateData?.info?.ticket_data)
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),
},
});
@ -159,7 +122,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
@ -168,4 +131,48 @@ export async function POST(request: Request) {
{ status: 500 },
);
}
}
export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentTemplate,
secret:string,private_key:string):Promise<Buffer<ArrayBufferLike>>
{
//Calculate hash
const originalTicket_data = templateData?.info?.ticket_data //expect valid json string
// console.log("templateData",templateData?.info?.ticket_data)
if (originalTicket_data && typeof originalTicket_data === 'string') {
try {
const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data
if (originalTicketObj && typeof originalTicketObj === 'object') {
console.log("Found valid ticket data: hash it")
// คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ)
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
const expireTimestamp = Math.floor(Date.now() / 1000) + TICKET_SEC;
const fullObj = { ...originalTicketObj, exp: expireTimestamp };
const sortedObj = sortObjectKeys(fullObj);
// แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash
const ticket_data = JSON.stringify(sortedObj);
// console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data)
const HmacSha256 = generateHmacSha256(ticket_data, secret)
const Ed25519 = signEd25519(ticket_data, private_key)
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
}
} catch (error) { //just skip
console.error("Set custom property fail",error);
}
}
// Apply Dynamic Content
if (templateData?.dc && typeof templateData?.dc === "object") {
console.log("Apply Dynamic Content")
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
}
// Apply Content Control
if (templateData?.cc && typeof templateData?.cc === "object") {
console.log("Apply Content Control")
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
}
return templateBuffer
}

View file

@ -1,29 +0,0 @@
{
"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": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
}

View file

@ -1,298 +0,0 @@
// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties
// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md
//
// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด)
//
// สิ่งที่แก้ในไฟล์ docx:
// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1"
// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย
// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้)
// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย
// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone"
// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค)
// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้
// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr
// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml
// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml
import PizzaZip from "pizzip";
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import xpath from "xpath";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx");
const outPath = process.argv[3] ?? inPath;
// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร)
const CUSTOM_PROPS = {
token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo",
"primary-key": "DOC-2026-000123",
owner: "u1001",
};
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const CUSTOM_NS =
"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
const VT_NS =
"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
const REL_CUSTOM =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
const CT_CUSTOM =
"application/vnd.openxmlformats-officedocument.custom-properties+xml";
const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
const parser = new DOMParser();
const serializer = new XMLSerializer();
const parseXml = (text) => parser.parseFromString(text, "text/xml");
// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี
// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid)
const serialize = (doc) => {
const text = serializer.serializeToString(doc);
return text.startsWith("<?xml")
? text
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + text;
};
// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone")
// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ
function unlockContentControls(zip) {
const select = xpath.useNamespaces({ w: W_NS });
const asNode = (n) => n;
const parts = Object.keys(zip.files).filter((n) =>
/^word\/(document|header\d*|footer\d*)\.xml$/.test(n),
);
// id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part)
let nextId = 999;
for (const part of parts) {
const ids = select(
"//w:permStart/@w:id | //w:permEnd/@w:id",
asNode(parseXml(zip.file(part).asText())),
);
for (const id of ids) {
const n = Number.parseInt(id.value, 10);
if (Number.isFinite(n) && n >= nextId) nextId = n;
}
}
let wrapped = 0;
let locked = 0;
for (const part of parts) {
const doc = parseXml(zip.file(part).asText());
// เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template
// และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ)
const sdts = select(
"//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]",
asNode(doc),
);
if (sdts.length === 0) continue;
for (const sdt of sdts) {
// เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission
const sdtPr = select("./w:sdtPr", sdt)[0];
const hasLock = select("./w:lock", sdtPr).length > 0;
if (!hasLock) {
const lock = doc.createElementNS(W_NS, "w:lock");
lock.setAttribute("w:val", "sdtLocked");
// w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr
const idEl = select("./w:id", sdtPr)[0];
if (idEl && idEl.nextSibling) {
sdtPr.insertBefore(lock, idEl.nextSibling);
} else {
sdtPr.appendChild(lock);
}
locked++;
}
const start = doc.createElementNS(W_NS, "w:permStart");
start.setAttribute("w:id", String(nextId));
start.setAttribute("w:edGrp", "everyone");
const end = doc.createElementNS(W_NS, "w:permEnd");
end.setAttribute("w:id", String(nextId));
nextId++;
sdt.parentNode.insertBefore(start, sdt);
sdt.parentNode.insertBefore(end, sdt.nextSibling);
wrapped++;
}
zip.file(part, serialize(doc));
}
return { wrapped, locked };
}
function enableReadOnlyProtection(zip) {
const file = zip.file("word/settings.xml");
if (!file) throw new Error("word/settings.xml not found");
const doc = parseXml(file.asText());
const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection");
const prot = prots.length > 0 ? prots[0] : null;
if (prot) {
prot.setAttribute("w:edit", "readOnly");
prot.setAttribute("w:enforcement", "1");
} else {
// T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings
const el = doc.createElementNS(W_NS, "w:documentProtection");
el.setAttribute("w:edit", "readOnly");
el.setAttribute("w:enforcement", "1");
doc.documentElement.insertBefore(el, doc.documentElement.firstChild);
}
zip.file("word/settings.xml", serialize(doc));
}
function setCustomProperties(zip, props) {
// --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม ---
const existing = zip.file("docProps/custom.xml");
let doc;
if (existing) {
doc = parseXml(existing.asText());
// ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่)
const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property"));
for (const p of all) {
if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) {
p.parentNode.removeChild(p);
}
}
} else {
doc = parseXml(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
);
}
// pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1
const usedPids = Array.from(
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1);
let nextPid = Math.max(1, ...usedPids) + 1;
for (const [name, value] of Object.entries(props)) {
const prop = doc.createElementNS(CUSTOM_NS, "property");
prop.setAttribute("fmtid", FMTID);
prop.setAttribute("pid", String(nextPid++));
prop.setAttribute("name", name);
const val = doc.createElementNS(VT_NS, "vt:lpwstr");
val.textContent = value;
prop.appendChild(val);
doc.documentElement.appendChild(prop);
}
zip.file("docProps/custom.xml", serialize(doc));
// --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) ---
const ct = parseXml(zip.file("[Content_Types].xml").asText());
const hasCt = Array.from(ct.getElementsByTagName("Override")).some(
(o) => o.getAttribute("PartName") === "/docProps/custom.xml",
);
if (!hasCt) {
const el = ct.createElement("Override");
el.setAttribute("PartName", "/docProps/custom.xml");
el.setAttribute("ContentType", CT_CUSTOM);
ct.documentElement.appendChild(el);
zip.file("[Content_Types].xml", serialize(ct));
}
// --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) ---
const rels = parseXml(zip.file("_rels/.rels").asText());
const relNodes = Array.from(rels.getElementsByTagName("Relationship"));
if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) {
const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id")));
let n = 1;
while (usedIds.has(`rId${n}`)) n++;
const el = rels.createElement("Relationship");
el.setAttribute("Id", `rId${n}`);
el.setAttribute("Type", REL_CUSTOM);
el.setAttribute("Target", "docProps/custom.xml");
rels.documentElement.appendChild(el);
zip.file("_rels/.rels", serialize(rels));
}
}
// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์
function verify(path) {
const zip = new PizzaZip(readFileSync(path));
const settings = zip.file("word/settings.xml")?.asText() ?? "";
const prot = settings.match(/<w:documentProtection[^>]*\/>/)?.[0];
const enforced =
prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"');
const customXml = zip.file("docProps/custom.xml")?.asText();
const propsBack = {};
if (customXml) {
const doc = parseXml(customXml);
for (const p of Array.from(
doc.getElementsByTagNameNS(CUSTOM_NS, "property"),
)) {
propsBack[p.getAttribute("name")] = p.textContent;
}
}
const rels = zip.file("_rels/.rels")?.asText() ?? "";
const ct = zip.file("[Content_Types].xml")?.asText() ?? "";
const docXml = zip.file("word/document.xml")?.asText() ?? "";
const sdtPrs = docXml.match(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
const rich = sdtPrs.length - plain;
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
const missingLock = sdtPrs.filter(
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
).length;
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
console.log(
"custom properties :",
Object.entries(propsBack)
.map(([k, v]) => `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`)
.join(", ") || "(ว่าง)",
);
console.log(
`relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`,
);
console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`);
console.log(
`permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` +
(permStarts === tagged && permEnds === tagged ? "✓" : "✗"),
);
console.log(
`CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`,
);
return (
enforced &&
!!customXml &&
permStarts === tagged &&
permEnds === tagged &&
missingLock === 0
);
}
const zip = new PizzaZip(readFileSync(inPath));
enableReadOnlyProtection(zip);
const { wrapped, locked } = unlockContentControls(zip);
setCustomProperties(zip, CUSTOM_PROPS);
if (locked > 0) {
console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`);
}
console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`);
const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" });
writeFileSync(outPath, out);
console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`);
const ok = verify(outPath);
process.exit(ok ? 0 : 1);

View file

@ -1,127 +0,0 @@
# 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 เตรียมไว้ก่อน
# ทดลอง Restrict Edit + Custom Properties (2026-10-07)
สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง
```sh
node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS)
```
## Restrict Edit (เปิดผ่านโค้ด)
- อยู่ที่ `word/settings.xml` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar)
- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย
- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ)
## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner)
ทางเลือกที่ศึกษา:
1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+)
- ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย:
1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr`
2. `[Content_Types].xml` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>`
3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml`
2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token
3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก
gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt
- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง
## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07)
- ค่า OOXML คือ `<w:jc w:val="thaiDistribute"/>` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์
- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ)
- วิธีจัดเองใน Word:
- รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed
- ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed
- จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด
- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render
- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`)
## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์)
- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓
- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓
- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓
- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓
- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓
- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓
## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1)
- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้
- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2)
- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password
## เหลือตรวจใน Word จริง (รอบ 2)
- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5)
- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้)
- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ
- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline)

Binary file not shown.

View file

@ -1,43 +0,0 @@
@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--

33
app/page.tsx Normal file
View file

@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
export default function RootPage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
const entries = Object.entries(searchParams);
const isEcourtLink = entries.some(([key]) =>
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
);
if (!isEcourtLink) {
redirect("/wordaddin");
}
const qs = new URLSearchParams();
for (const [key, value] of entries) {
if (typeof value === "string") {
qs.set(key, value);
} else if (Array.isArray(value)) {
for (const v of value) qs.append(key, v);
}
}
redirect(`/note?${qs.toString()}`);
}

100
app/tmpl/page.tsx Normal file
View file

@ -0,0 +1,100 @@
// app/page.tsx (ระบบ B)
'use client';
import type { ChangeEvent } from 'react';
// import { createDownloadFormAction } from './actions';
import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown';
export default function HomePage() {
const templateData: DropdownData = {
T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'],
T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'],
};
const handleTmplSelect = (template: string, data: string) => {
//console.log('Location Selected:', { template, data });
};
const handleInput = (e: ChangeEvent<HTMLInputElement>)=>{
// console.log(e.target)
return true;
}
return (
<div style={{ padding: '2rem' }}>
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
<div>
ตัวอย่าง Frontend ส่งข้อมูลผ่าน &lt;form&gt;(POST) หรือ &lt;a&gt;(GET)
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<form action="../api/template/download-docx" method="POST">
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
<DependentDropdown data={templateData} name1='tid' name2='fid'
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
<div className="mb-2"></div>
<div>
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
</div>
<div>
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
}}
>
ดาวน์โหลดเอกสาร MS Word
</button>
</form>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">A Link Get</h2>
<a href="../api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</a><br/>
<a href="../api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</a><br/>
<a href="../api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</a><br/>
<a href="../api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</a><br/>
<a href="../api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>, &nbsp;
<a href="../tmpl/T12_X.docx">T12_X.docx</a><br/>
Data: <a href="../tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp;
<a href="../tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp;
<a href="../tmpl/TS_P2_IN.json">TS_P2_IN.json</a><br/>
<a href="../tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp;
<a href="../tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
<div className="text-xl font-bold text-center">
หน้านี้(เลือกไฟล์)<br/>
&darr; <br/>
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
&darr; <br/>
generate(เปิดเทมเพลต,สร้าง docx)<br/>
&darr; <br/>
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
&darr; <br/>
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
&darr; <br/>
Versioning(คุมเวอร์ชั่น)<br/>
&darr; <br/>
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
</div>
</div>
</div>
</div>
);
}

10
app/wordaddin/layout.tsx Normal file
View file

@ -0,0 +1,10 @@
import type { Metadata } from "next";
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
export const metadata: Metadata = {
title: "e-Court Documents Versioning",
};
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
import { requestOrigin } from "@/utils/request-origin";
// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload
//
// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม
// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้
// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route
// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว
// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template
// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น)
//
// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker
// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image)
/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */
const DEV_ORIGIN = "https://localhost:3000";
const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml");
export function GET(req: Request): Response {
const template = fs.readFileSync(TEMPLATE, "utf8");
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
return new Response(manifest, {
headers: {
"content-type": "application/xml; charset=utf-8",
// origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download
"cache-control": "no-store",
},
});
}

7
app/wordaddin/page.tsx Normal file
View file

@ -0,0 +1,7 @@
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
export default function WordAddinPage() {
return <WordAddinGate />;
}

View file

@ -0,0 +1,91 @@
'use client';
import { useState, ChangeEvent, useEffect } from 'react';
export interface DropdownData {
[key: string]: string[];
}
interface DependentDropdownProps {
data: DropdownData;
name1: string; // เพิ่มมาเพื่อใช้กับ Form Action
name2: string; // เพิ่มมาเพื่อใช้กับ Form Action
label1?: string;
label2?: string;
placeholder1?: string;
placeholder2?: string;
onSelectChange?: (mainValue: string, subValue: string) => void;
}
export default function DependentDropdown({
data,
name1,
name2,
label1 = 'เลือกตัวเลือกหลัก:',
label2 = 'เลือกตัวเลือกย่อย:',
placeholder1 = '-- กรุณาเลือก --',
placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --',
onSelectChange,
}: DependentDropdownProps) {
const [mainSelection, setMainSelection] = useState<string>('');
const [subSelection, setSubSelection] = useState<string>('');
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setMainSelection(value);
setSubSelection('');
};
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
setSubSelection(e.target.value);
};
useEffect(() => {
if (onSelectChange) {
onSelectChange(mainSelection, subSelection);
}
}, [mainSelection, subSelection, onSelectChange]);
const subOptions = mainSelection ? data[mainSelection] : [];
return (
<div className="space-y-4 w-full">
{/* Dropdown ที่ 1 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label1}</label>
<select
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={mainSelection}
onChange={handleMainChange}
required
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
>
<option value="">{placeholder1}</option>
{Object.keys(data).map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
{/* Dropdown ที่ 2 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label2}</label>
<select
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={subSelection}
onChange={handleSubChange}
disabled={!mainSelection}
required
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
>
<option value="">
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
</option>
{subOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</div>
</div>
);
}

View file

@ -0,0 +1,166 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Spinner } from "./Spinner";
/**
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
* (pattern overlay เดียวกับ AnnotationSidebar)
*/
const DEFAULT_TITLE = "สร้าง version ใหม่?";
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
const BTN_SECONDARY =
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
const BTN_PRIMARY =
"focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
export function ConfirmVersionDialog({
open,
busy,
title,
description,
confirmLabel,
onConfirm,
onConfirmClose,
onDismiss,
}: {
open: boolean;
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
busy: boolean;
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
title?: string;
description?: ReactNode;
confirmLabel?: string;
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
onConfirm: (message: string) => void;
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
onConfirmClose?: (message: string) => void;
onDismiss: () => void;
}) {
const [message, setMessage] = useState("");
const dialogRef = useRef<HTMLDivElement>(null);
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
useEffect(() => {
if (!open) {
setMessage("");
}
}, [open]);
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) onDismiss();
if (event.key === "Tab") {
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
);
const first = focusables?.item(0);
const last = focusables?.item((focusables?.length ?? 0) - 1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onDismiss]);
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previous?.focus();
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
onClick={() => !busy && onDismiss()}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title ?? DEFAULT_TITLE}
tabIndex={-1}
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
onClick={(event) => event.stopPropagation()}
>
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
คำอธิบาย version (ไม่บังคับ)
</label>
<textarea
id="wordaddin-version-message"
className="genesis-input mt-1 block w-full resize-y"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
maxLength={2048}
rows={3}
disabled={busy}
aria-label="คำอธิบาย version"
/>
{busy ? (
<div className="mt-3">
<Spinner label="กำลังดำเนินการ…" />
</div>
) : null}
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
<div className="mt-4 flex flex-col gap-2">
<div className="flex justify-end gap-2">
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
ยกเลิก
</button>
<button
type="button"
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
disabled={busy}
onClick={() => onConfirm(message)}
>
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
</button>
</div>
{onConfirmClose ? (
<button
type="button"
className={`${BTN_PRIMARY} w-full`}
disabled={busy}
onClick={() => onConfirmClose(message)}
>
บันทึกเวอร์ชันและปิดเอกสาร
</button>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default ConfirmVersionDialog;

View file

@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
/**
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
* แถบกว้างเท่ากันทั้งคอลัมน์
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
*/
const ICONS = {
error: CircleAlert,
warning: TriangleAlert,
success: CircleCheck,
} as const;
export function MessageBar({
intent,
children,
action,
}: {
intent: "error" | "warning" | "success";
children: ReactNode;
action?: ReactNode;
}) {
const Icon = ICONS[intent];
const tone =
intent === "error"
? "border-error/30 bg-error/10 text-error-ink"
: intent === "warning"
? "border-warning/30 bg-warning/15 text-warning-ink"
: "border-success/30 bg-success/10 text-success-ink";
return (
<div
role={intent === "error" ? "alert" : "status"}
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 break-words">{children}</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View file

@ -0,0 +1,65 @@
import { CircleCheck, Save, X } from "lucide-react";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
*/
export function SaveControls({
disabled,
busy,
status,
error,
closeSupported,
onSave,
onSaveAndClose,
}: {
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
disabled: boolean;
busy: boolean;
status: "saving" | "saved" | null;
error: string | null;
closeSupported: boolean;
onSave: () => void;
onSaveAndClose: () => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSave}
>
<Save className="size-4" aria-hidden />
บันทึก
</button>
{closeSupported ? (
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSaveAndClose}
>
<X className="size-4" aria-hidden />
บันทึกและปิดเอกสาร
</button>
) : null}
</div>
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
{!busy && status === "saved" ? (
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
<CircleCheck className="size-4 text-success-ink" aria-hidden />
บันทึกแล้ว
</p>
) : null}
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
</div>
);
}
export default SaveControls;

View file

@ -0,0 +1,13 @@
import { LoaderCircle } from "lucide-react";
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
return (
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
<LoaderCircle className="size-4 animate-spin" aria-hidden />
{label ? <span>{label}</span> : null}
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
</span>
);
}

View file

@ -0,0 +1,646 @@
/* global Office */
"use client";
import { useCallback, useEffect, useState } from "react";
import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react";
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
import * as authService from "@/wordaddin/services/authService";
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
import * as documentService from "@/wordaddin/services/documentService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import * as metadataService from "@/wordaddin/services/metadataService";
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog";
import { MessageBar } from "./MessageBar";
import SaveControls from "./SaveControls";
import { Spinner } from "./Spinner";
import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel";
/**
* Task pane หลัก — state machine ตาม Phase 5:
* checking → (ไม่มี token) signed-out
* → (มี token + ไม่ใช่ tracked document) untracked
* → (มี token + tracked document) tracking
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว
* เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles,
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
*
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
*
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
*/
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
type Tab = "versioning" | "template";
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
const tabButtonClass = (active: boolean) =>
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
}`;
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
function demoVersions(now: number): VersionRecord[] {
return [
{
versionId: "demo-v3",
versionNumber: 3,
createdAt: new Date(now - 3_600_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "ปรับคำร้องตามคำแนะนำอัยการ",
hasPdf: true,
fields: null,
},
{
versionId: "demo-v2",
versionNumber: 2,
createdAt: new Date(now - 86_400_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: null,
hasPdf: true,
fields: null,
},
{
versionId: "demo-v1",
versionNumber: 1,
createdAt: new Date(now - 172_800_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "สร้างเอกสารครั้งแรก",
hasPdf: false,
fields: null,
},
];
}
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
const [activeTab, setActiveTab] = useState<Tab>("versioning");
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
const [templateBusy, setTemplateBusy] = useState(false);
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
const [bootError, setBootError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [signingIn, setSigningIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [saving, setSaving] = useState(false);
const [progressMessage, setProgressMessage] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
const [flowError, setFlowError] = useState<string | null>(null);
const [flowWarning, setFlowWarning] = useState<string | null>(null);
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
const [closeSupported] = useState(() => documentService.canCloseDocument());
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
const [saveBusy, setSaveBusy] = useState(false);
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
const [hasUncommitted, setHasUncommitted] = useState(false);
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("signed-out");
setActiveTab("versioning");
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
return true;
}
return false;
}, []);
const refreshHistory = useCallback(
async (id: string) => {
setHistoryLoading(true);
setHistoryError(null);
try {
setVersions(await getVersions(id));
} catch (error) {
if (!handleAuthRequired(error)) {
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
}
} finally {
setHistoryLoading(false);
}
},
[handleAuthRequired],
);
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
* e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */
const detect = useCallback(async () => {
setPhase("checking");
setBootError(null);
try {
let properties: metadataService.TrackedDocumentProperties | null = null;
if (!(await authService.getAccessToken())) {
// e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login
// (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม)
try {
properties = await metadataService.readTrackedProperties();
} catch {
properties = null;
}
if (
!properties ||
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
) {
setPhase("signed-out");
return;
}
}
authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null);
properties ??= await metadataService.readTrackedProperties();
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined;
if (!id) {
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
setDocumentTemplateId(properties.templateId ?? null);
setPhase("untracked");
return;
}
setDocumentId(id);
setDocumentTemplateId(null);
setPhase("tracking");
await refreshHistory(id);
} catch (error) {
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
}
}, [refreshHistory]);
useEffect(() => {
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
void detect();
}, [demo, detect]);
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
useEffect(() => {
autoSaveService.onError = (error) =>
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
autoSaveService.onSaved = () => setAutoSaveError(null);
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
if (!demo) {
autoSaveService.start();
}
return () => {
autoSaveService.stop();
autoSaveService.onError = undefined;
autoSaveService.onSaved = undefined;
autoSaveService.onStateChange = undefined;
};
}, [demo]);
const handleSignIn = async () => {
setSigningIn(true);
setLoginError(null);
try {
const result = await authService.login();
if (result.ok) {
await detect();
} else {
setLoginError(result.error);
}
} catch (error) {
setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ"));
} finally {
setSigningIn(false);
}
};
const handleSignOut = () => {
authService.signOut();
setUsername(null);
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError(null);
setPhase("signed-out");
setActiveTab("versioning");
};
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
const handleSaveOnly = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.save();
setSaveStatus("saved");
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
const handleSaveAndClose = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.suspend();
try {
await autoSaveService.forceSave();
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
} finally {
autoSaveService.resume();
}
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
const handleStartVersioning = async (message: string) => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
setDocumentId(result.documentId);
setDocumentTemplateId(null);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(result.documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setStarting(false);
}
};
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
* message คือ commit message จาก dialog (FR-13)
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
if (!documentId) {
return;
}
setSaving(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
if (closeAfter) {
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
return;
}
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(documentId);
} catch (error) {
if (error instanceof VersionSavedButCloseFailedError) {
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
setFlowWarning(error.message);
await refreshHistory(documentId);
return;
}
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setSaving(false);
}
};
/**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
*/
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
if (!documentId) {
return;
}
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
setDownloading(version.versionId);
setFlowError(null);
try {
const link = await getDownloadLink(documentId, version.versionId, format);
if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(
format === "pdf"
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
);
} else {
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
}
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
}
} finally {
setDownloading(null);
}
};
return (
<div className="flex min-h-screen flex-col gap-2 p-4">
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
{phase !== "signed-out" && phase !== "checking" && username ? (
/* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */
<button
type="button"
className="focus-ring inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:text-primary"
onClick={handleSignOut}
aria-label={`ออกจากระบบ (${username})`}
title={`${username} — ออกจากระบบ`}
>
<LogOut className="size-3.5" aria-hidden />
ออกจากระบบ
</button>
) : null}
</div>
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
<MessageBar intent="warning">{autoSaveError}</MessageBar>
) : null}
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ)
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
{(phase === "untracked" || phase === "tracking") && (
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
<button
type="button"
aria-pressed={activeTab === "versioning"}
className={tabButtonClass(activeTab === "versioning")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("versioning")}
>
Versioning
</button>
<button
type="button"
aria-pressed={activeTab === "template"}
className={tabButtonClass(activeTab === "template")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("template")}
>
เครื่องมือ Template
</button>
</div>
)}
{phase === "checking" ? (
<div className="flex flex-col items-center gap-4 pt-10">
<Spinner label="กำลังตรวจสอบเอกสาร…" />
{bootError ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => void detect()}
>
ลองใหม่
</button>
}
>
{bootError}
</MessageBar>
) : null}
</div>
) : null}
{phase === "signed-out" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</p>
<p className="text-sm text-ink-soft">
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
</p>
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={signingIn}
onClick={() => void handleSignIn()}
>
{signingIn ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
</button>
{loginError ? <MessageBar intent="warning">{loginError}</MessageBar> : null}
</div>
) : null}
{phase === "untracked" && activeTab === "versioning" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
<p className="text-sm text-ink-soft">
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</p>
{documentTemplateId ? (
<p className="text-sm text-ink-soft">
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
และ sync ค่า fields ให้อัตโนมัติ
</p>
) : null}
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={starting}
onClick={() => setStartConfirmOpen(true)}
>
<FileUp className="size-4" aria-hidden />
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
</button>
<SaveControls
disabled={starting}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
</div>
) : null}
{phase === "tracking" && activeTab === "versioning" ? (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
<div className="flex flex-col gap-2 pt-4">
{/* กลุ่มข้อความสถานะ */}
{hasUncommitted ? (
<MessageBar intent="warning">
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save &amp; Version เพื่อบันทึกเป็น version ใหม่
</MessageBar>
) : null}
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
<div className="flex flex-col gap-2 pt-2">
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
disabled={saving}
onClick={() => setConfirmOpen(true)}
>
<GitCommitHorizontal className="size-4" aria-hidden />
Save &amp; Version
</button>
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
<SaveControls
disabled={saving}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
</div>
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
<VersionHistoryPanel
versions={versions}
loading={historyLoading}
error={historyError}
downloadingVersionId={downloadingVersionId}
downloadingPdfVersionId={downloadingPdfVersionId}
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)}
/>
</div>
) : null}
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
) : null}
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
เปิดค้างขณะสลับแท็บ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
<>
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
<ConfirmVersionDialog
open={confirmOpen}
busy={saving}
onConfirm={(message) => {
setConfirmOpen(false);
void handleSaveAndVersion(message);
}}
{...(closeSupported
? {
onConfirmClose: (message: string) => {
setConfirmOpen(false);
void handleSaveAndVersion(message, true);
},
}
: {})}
onDismiss={() => setConfirmOpen(false)}
/>
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
<ConfirmVersionDialog
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => {
setStartConfirmOpen(false);
void handleStartVersioning(message);
}}
onDismiss={() => setStartConfirmOpen(false)}
/>
</>
) : null}
</div>
);
};
export default TaskpaneApp;

View file

@ -0,0 +1,411 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react";
import {
applyTestValues,
insertBreakAtSelection,
insertParagraphAtSelection,
listContentControls,
selectContentControl,
supportsAllContentControlTypes,
type ScannedField,
} from "@/wordaddin/services/templateService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import { MessageBar } from "./MessageBar";
/**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template
* สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking:
* 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง
* 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที
* โดยไม่ต้อง render ผ่าน /api/template/render)
* 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง
*
* state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง
* ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น)
*/
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
const BTN_SECONDARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */
const BTN_INSERT =
"focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) {
/** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */
const [fields, setFields] = useState<ScannedField[] | null>(null);
const [scanning, setScanning] = useState(false);
const [applying, setApplying] = useState(false);
const [insertingParagraph, setInsertingParagraph] = useState(false);
const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null);
/** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(null);
const busy = scanning || applying || insertingParagraph || insertingBreak !== null;
/** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ
* Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount
* แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */
useEffect(() => {
onBusyChange?.(busy);
return () => onBusyChange?.(false);
}, [busy, onBusyChange]);
/** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */
const uniqueTags = useMemo(() => {
if (!fields) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}, [fields]);
const emptyTagCount = useMemo(
() => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0),
[fields],
);
/** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */
const duplicateTags = useMemo(() => {
if (!fields) return [];
const counts = new Map<string, number>();
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "") continue;
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([tag]) => tag);
}, [fields]);
/** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย
* แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */
const hasFilledValue = useMemo(
() => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""),
[uniqueTags, values],
);
/**
* สแกนเอกสาร + เติมค่าฟอร์ม
* - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag
* พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว)
* - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม
* และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ
* **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่
* (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน)
*/
const runScan = async (merge: boolean) => {
setScanning(true);
setError(null);
if (!merge) {
setSuccess(null);
setWarning(null);
}
try {
const scanned = await listContentControls();
setFields(scanned);
// Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล
// ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน
setCapabilityWarning(
supportsAllContentControlTypes()
? null
: "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้",
);
setValues((previous) => {
// สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่
// (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป)
const next: Record<string, string> = {};
const seen = new Set<string>();
for (const field of scanned) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
next[tag] = merge && tag in previous ? previous[tag] : field.text;
}
return next;
});
} catch (scanError) {
setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ"));
} finally {
setScanning(false);
}
};
const handleView = async (id: number) => {
setError(null);
try {
await selectContentControl(id);
} catch (viewError) {
setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ"));
}
};
/** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */
const handleInsertParagraph = async () => {
setInsertingParagraph(true);
setError(null);
try {
await insertParagraphAtSelection();
} catch (insertError) {
setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ"));
} finally {
setInsertingParagraph(false);
}
};
const handleInsertBreak = async (breakKind: "line" | "page") => {
setInsertingBreak(breakKind);
setError(null);
try {
await insertBreakAtSelection(breakKind);
} catch (insertError) {
setError(
getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"),
);
} finally {
setInsertingBreak(null);
}
};
const handleApply = async () => {
setApplying(true);
setError(null);
setSuccess(null);
setWarning(null);
try {
const result = await applyTestValues(values);
if (result.appliedCount === 0 && result.failures.length === 0) {
setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่");
} else {
if (result.appliedCount > 0) {
setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`);
}
const problems: string[] = [];
if (result.missingTags.length > 0) {
problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`);
}
if (result.failures.length > 0) {
const first = result.failures[0];
problems.push(
`เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures
.map((failure) => failure.tag)
.join(", ")}) — ${first.reason}`,
);
}
if (problems.length > 0) {
setWarning(
`${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`,
);
}
}
// สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ
await runScan(true);
} catch (applyError) {
setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ"));
} finally {
setApplying(false);
}
};
return (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
<div className="flex flex-col gap-2 pt-4">
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
{/* §1 ตรวจสอบ Content Controls */}
<div className="mt-2 flex items-baseline justify-between gap-2">
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
</div>
<button
type="button"
className={`${BTN_SECONDARY} w-full justify-center`}
disabled={busy}
aria-busy={scanning}
onClick={() => void runScan(false)}
>
{scanning ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<ScanSearch className="size-4" aria-hidden />
)}
สแกนช่องในเอกสาร
</button>
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
<MessageBar intent="warning">
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
{duplicateTags.length > 0
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
: ""}
</MessageBar>
) : null}
{!fields && !scanning ? (
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
) : null}
{fields && fields.length === 0 && !scanning ? (
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
) : null}
{fields && fields.length > 0 ? (
<ol className="relative">
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
{fields.map((field, index) => (
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-ink-faint">{index + 1}.</span>
<span
className="break-all text-sm font-medium text-ink"
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
>
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
</span>
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
</span>
<span className="ml-auto">
<button
type="button"
className={BTN_ACTION}
disabled={busy}
onClick={() => void handleView(field.id)}
>
<Crosshair className="size-3.5" aria-hidden />
ดูช่อง
</button>
</span>
</div>
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
{field.text.trim() === "" ? "(ว่าง)" : field.text}
</p>
</li>
))}
</ol>
) : null}
{/* §2 กรอกค่าทดสอบ */}
{uniqueTags.length > 0 ? (
<>
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
<p className="text-sm text-ink-soft">
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
</p>
<div className="flex flex-col gap-2" aria-busy={applying}>
{uniqueTags.map((tag, index) => (
<div key={tag} className="flex flex-col gap-1">
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
{tag}
</label>
<input
id={`wordaddin-template-value-${index}`}
className="genesis-input block w-full"
value={values[tag] ?? ""}
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
disabled={busy}
/>
</div>
))}
</div>
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center`}
disabled={busy || !hasFilledValue}
onClick={() => void handleApply()}
>
<PenLine className="size-4" aria-hidden />
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
</button>
</>
) : fields ? (
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
) : (
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
)}
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
<div className="grid grid-cols-3 gap-1">
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingParagraph}
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
onClick={() => void handleInsertParagraph()}
>
{insertingParagraph ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<CornerDownLeft className="size-4" aria-hidden />
)}
ย่อหน้า
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "line"}
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
onClick={() => void handleInsertBreak("line")}
>
{insertingBreak === "line" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<WrapText className="size-4" aria-hidden />
)}
แบ่งบรรทัด
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "page"}
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
onClick={() => void handleInsertBreak("page")}
>
{insertingBreak === "page" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<FilePlus className="size-4" aria-hidden />
)}
แบ่งหน้า
</button>
</div>
</div>
);
}
export default TemplateToolsPanel;

View file

@ -0,0 +1,149 @@
import { Download, FileText, LoaderCircle } from "lucide-react";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
*/
function formatTimestamp(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
export function VersionHistoryPanel({
versions,
loading,
error,
downloadingVersionId,
downloadingPdfVersionId,
onDownloadPdf,
onDownload,
onRetry,
}: {
versions: VersionRecord[];
loading: boolean;
error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null;
downloadingPdfVersionId: string | null;
onDownloadPdf: (version: VersionRecord) => void;
onDownload: (version: VersionRecord) => void;
onRetry: () => void;
}) {
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
return (
<div className="flex flex-col gap-1.5">
<div className="mt-4 mb-2 flex items-baseline justify-between">
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
</div>
{loading ? <Spinner label="กำลังโหลด…" /> : null}
{error ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={onRetry}
>
ลองใหม่
</button>
}
>
{error}
</MessageBar>
) : null}
{!loading && !error && versions.length === 0 ? (
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
) : null}
{versions.length > 0 ? (
<ol className="relative">
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
{versions.length > 1 ? (
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
) : null}
{versions.map((version, index) => {
const isLatest = index === 0;
return (
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
{isLatest ? (
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
) : null}
<span className="ml-auto flex items-center gap-1">
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingVersionId === version.versionId}
onClick={() => onDownload(version)}
>
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
{downloadingVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<Download className="size-3.5" aria-hidden />
)}
ดาวน์โหลด
</button>
{version.hasPdf ? (
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingPdfVersionId === version.versionId}
onClick={() => onDownloadPdf(version)}
>
{downloadingPdfVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<FileText className="size-3.5" aria-hidden />
)}
PDF
</button>
) : null}
</span>
</div>
{version.message ? (
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
{version.message}
</p>
) : null}
<p className="mt-0.5 text-xs text-ink-soft">
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
</p>
</li>
);
})}
</ol>
) : null}
</div>
);
}
export default VersionHistoryPanel;

View file

@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import { withBasePath } from "@/utils/base-path";
import { signInForDemo } from "@/wordaddin/services/authService";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
import TaskpaneApp from "./TaskpaneApp";
/**
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
* Office ที่ module top-level จึงปลอดภัย)
*
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
*/
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
const ONREADY_FALLBACK_MS = 5_000;
/**
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
*/
let officeReadyPromise: Promise<boolean> | null = null;
function ensureOfficeJs(): Promise<boolean> {
if (!officeReadyPromise) {
officeReadyPromise = new Promise<boolean>((resolve) => {
if (typeof Office !== "undefined") {
void Office.onReady(() => resolve(true));
return;
}
const script = document.createElement("script");
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
const onReady = () => {
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
void Office.onReady(() => {
clearTimeout(fallbackTimer);
resolve(true);
});
};
script.onload = onReady;
script.onerror = () => {
clearTimeout(fallbackTimer);
officeReadyPromise = null;
script.remove();
resolve(false);
};
script.src = OFFICE_JS_SRC;
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
script.id = "office-js";
document.head.appendChild(script);
});
}
return officeReadyPromise;
}
type GateStatus = "loading" | "ready" | "error";
export function WordAddinGate() {
const [status, setStatus] = useState<GateStatus>("loading");
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
useEffect(() => {
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
signInForDemo();
setDemo(demoParam);
setStatus("ready");
return;
}
let cancelled = false;
void ensureOfficeJs()
.then((ok) => {
if (!cancelled) setStatus(ok ? "ready" : "error");
})
.catch(() => {
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, []);
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
</div>
);
}
if (status === "error") {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => window.location.reload()}
>
ลองใหม่
</button>
}
>
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
</MessageBar>
</div>
);
}
return <TaskpaneApp demo={demo ?? undefined} />;
}
export default WordAddinGate;

View file

@ -253,12 +253,76 @@ export const annotationVersions = pgTable(
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
);
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
docId: text("doc_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
);
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
export const documentVersions = pgTable(
"document_versions",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id")
.notNull()
.references(() => documents.id, { onDelete: "cascade" }),
versionNumber: integer("version_number").notNull(),
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
clientVersionId: text("client_version_id"),
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
contentHash: text("content_hash").notNull(),
/** commit message (FR-13) — trim แล้วว่าง = NULL */
message: text("message"),
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
fields: jsonb("fields").$type<Record<string, string>>(),
templateId: text("template_id"),
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
fileName: text("file_name"),
docxObjectKey: text("docx_object_key").notNull(),
pdfObjectKey: text("pdf_object_key"),
docxSize: integer("docx_size").notNull(),
pdfSize: integer("pdf_size"),
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
],
);
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
export type AnnotationRow = typeof annotations.$inferSelect;
export type BookmarkRow = typeof bookmarks.$inferSelect;
export type ReplyRow = typeof annotationReplies.$inferSelect;
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
export type DocumentRow = typeof documents.$inferSelect;
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
export type AnnotationDTO = {
id: string;
@ -371,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
createdAt: row.createdAt.toISOString(),
};
}
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
export type DocumentVersionDTO = {
versionId: string;
versionNumber: number;
createdAt: string;
createdByUserId: string;
createdByUserName: string;
contentHash: string;
fields: Record<string, string> | null;
message: string | null;
hasPdf: boolean;
};
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
return {
versionId: row.id,
versionNumber: row.versionNumber,
createdAt: row.createdAt.toISOString(),
createdByUserId: row.userId,
createdByUserName: row.userName,
contentHash: row.contentHash,
fields: row.fields,
message: row.message,
hasPdf: row.pdfObjectKey !== null,
};
}

58
docker-compose.yml Normal file
View file

@ -0,0 +1,58 @@
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
#
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
services:
db:
image: postgres:17-alpine
container_name: judes-note-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
POSTGRES_DB: ecourt-note
ports:
- "54322:5432"
volumes:
- judes-note-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 3s
retries: 10
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
minio:
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: judes-note-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
- "19001:9001" # console ดู bucket/object ผ่าน browser
volumes:
- judes-note-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
volumes:
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
judes-note-db-data:
name: judes-note-db-data
judes-note-minio-data:
name: judes-note-minio-data

View file

@ -2,8 +2,9 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# postinstall รัน scripts/patch-pdf-worker.mjs + scripts/copy-swagger-ui.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
RUN npm ci
@ -16,13 +17,26 @@ COPY . .
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs
# + ดาวน์โหลด Office.js runtime ล่าสุด (public/wordaddin/office-js/) ของ taskpane wordaddin
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs
ENV NEXT_TELEMETRY_DISABLED=1
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
ENV MINIO_ENDPOINT=127.0.0.1
ENV MINIO_PORT=19000
ENV MINIO_USE_SSL=false
ENV MINIO_ACCESS_KEY=minioadmin
ENV MINIO_SECRET_KEY=minioadmin
ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54
ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e
ENV DOCVER_TOKEN_TTL_SECONDS=604800
ENV MINIO_BUCKET=docver
ENV DOCVER_LINK_TTL_SECONDS=900
RUN npm run build
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
FROM node:24-alpine AS runner
WORKDIR /app
@ -31,10 +45,14 @@ ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
# Next 14.2 รวม public/ (pdf.worker, ฟอนต์ Sarabun, samples PDF) ไว้ใน standalone แล้ว
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
# Next 14.2 standalone **ไม่รวม public/ และ .next/static เอง** — ต้องคัดลอกเองทั้งคู่
# (ก่อนหน้านี้เข้าใจผิดว่า Next รวม public/ ให้: จริง ๆ มันหลุดเข้ามาแค่บางส่วนผ่าน output
# file tracing ที่ตาม fs.readFile ของ server code (public/note จาก lib/server-pdf.ts,
# ฟอนต์ Sarabun จาก export route) — public/wordaddin/office-js ไม่มีใครอ่านผ่าน fs
# จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts

222
docs/PLAN-DOCVER.md Normal file
View file

@ -0,0 +1,222 @@
# PLAN-DOCVER — Document Versioning API (stand-alone)
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
## 0. บริบท/เป้าหมาย
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
`wordaddin/services/versioningService.ts`):
- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid`
(string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก
endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
response)
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22
— เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
พาหะ identity เดียว
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
ของ N10) · ไม่มี CORS
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
(client-only zone) type define เองใน `db/schema.ts`/`lib`
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
middleware เหมือน `api/template`)
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
JSON body (allowlist — key อื่น = 400 กัน typo):
| field | type | หมายเหตุ |
|---|---|---|
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid |
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
### `POST /api/documents/versions` — อัปโหลด version ใหม่
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ
`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป
multipart/form-data:
| field | type | หมายเหตุ |
|---|---|---|
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) |
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) —
**breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว
(ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
"สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
(= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server**
(client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token)
response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in +
ส่วนเสริม `createdByUserName`):
```ts
{
versionId: string; // = id ของ row (uuid)
versionNumber: number;
createdAt: string; // ISO-8601
createdByUserId: string; // = user_id
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
fields: Record<string, string> | null;
message: string | null;
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
}
```
- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ
N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf`
- format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400
- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id
ข้ามเอกสาร) — ไม่เจอ = 404
- `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
- response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET**
(อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ
ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
### ตาราง error
| เหตุ | สถานะ |
|---|---|
| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 |
| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 |
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
## 2. ตาราง (db/schema.ts)
```
documents — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
document_versions — id uuid PK · document_id uuid FK→documents CASCADE
· version_number int NOT NULL (+ UNIQUE(document_id, version_number))
· client_version_id · content_hash NOT NULL · message · fields jsonb
· template_id · file_name · docx_object_key NOT NULL · pdf_object_key
· docx_size int NOT NULL · pdf_size int · user_id NOT NULL
· user_name NOT NULL · created_at
+ index (document_id, created_at)
```
- **`documents` ≠ `files`** ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน
(ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
- `content_hash`/`docx_size` เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน
insert เสมอ · `hasPdf` **ไม่มี column** — derive จาก `pdf_object_key !== null`
- object key ใน MinIO: `docver/<versionId>/document.docx` / `document.pdf` — **uuid ล้วน**
ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · `versionId` สร้าง
ด้วย `randomUUID()` ฝั่ง server แล้วส่งเป็น `id` ตอน insert (object เขียนไปก่อนแล้ว)
## 3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน
เลือก: **insert-on-conflict + `SELECT … FOR UPDATE` ใน transaction เดียว** (ใช้ `.for("update")`
ที่ repo ใช้อยู่แล้วใน `app/actions/annotations.ts`):
1. `INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING` — สร้างเอกสารถ้ายังไม่มี
2. `SELECT id FROM documents WHERE doc_id = … FOR UPDATE` — serialize ผู้จ่ายเลขรายเอกสาร
(unique `doc_id` → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
3. `SELECT max(version_number) + 1` แล้ว insert row version
ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) ·
`pg_advisory_xact_lock` (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE
`(document_id, version_number)` เก็บไว้เป็น safety net
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า
validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId`
→ **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
- crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) —
**ไม่ทำ GC รอบนี้** บันทึกไว้เป็นงานอนาคต
- ไม่มี idempotency บน `clientVersionId` — retry หลัง network timeout เกิด version ใหม่
(client แก้เลขจาก response เองได้) ยอมรับได้
## 5. Carve-out จาก invariant #4 (CLAUDE.md)
กลุ่ม API นี้ **เขียน DB ผ่าน route handler** (ไม่ใช่ Server Action) และไม่ publish SSE
ไม่ `revalidatePath` — ตาราง `documents`/`document_versions` เป็นของกลุ่มนี้ล้วน ไม่มีหน้า
note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ
read-only ของ N10 แต่ตัวนี้เขียนได้)
## 6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)
| env | default | หมายเหตุ |
|---|---|---|
| `MINIO_ENDPOINT` | — บังคับ | host อย่างเดียว ไม่มี scheme |
| `MINIO_PORT` | `9000` | dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง) |
| `MINIO_USE_SSL` | `false` | |
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ |
| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
ตอนดาวน์โหลด
## 7. ไฟล์ของกลุ่มนี้
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer`
+ `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`)
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key)
- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token)
- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure
## 8. Out of scope รอบนี้
- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`)
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)

75
docs/WORDADDIN-DEV.md Normal file
View file

@ -0,0 +1,75 @@
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
## ไฟล์หลัก
| ที่ | คือ |
|---|---|
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler |
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock |
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
## Env
| ตัวแปร | default | ใช้เมื่อไหร่ |
|---|---|---|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) |
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` |
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
manifest ต้องชี้ https — 2 ทางเลือก:
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
## Sideload ใน Word Desktop (Windows)
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word
2. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
3. ชื่อไฟล์ไม่สำคัญ — Word สแกน `*.xml` ทุกไฟล์ในโฟลเดอร์ โดย `manifest.template.xml` คือ dev manifest อยู่แล้ว (URL localhost:3000) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout อยู่เสมอ — **ห้ามวาง xml manifest อื่นเพิ่มในโฟลเดอร์นี้** (GUID เดียวกันสองไฟล์ = Word สับสน)
เครื่องที่ไม่มี repo (production/เครื่องผู้ใช้):
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
2. เอาไฟล์ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
3. ทำขั้น Trust Center ข้างบนด้วยโฟลเดอร์นั้น > restart Word > **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ)
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
## ทดสอบ e-Court pass-through (ข้าม login)
ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะเข้าใช้ได้เลยโดยไม่ต้องกด Sign in — `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ)
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login
## การสลับกลับของจริง (วันหน้า)
- **Auth:** `wordaddin/services/authService.ts` เป็น mock คง surface เดิมครบ (`login/getAccessToken/refreshAccessToken/getUsername/signOut/startAutoRefresh/...`) — เขียน implementation จริงทับ (ต้นฉบับ Keycloak PKCE + Office Dialog API อยู่ที่ `D:\word-addin-poc\addin\src\services\authService.ts`; ถ้าใช้ ต้องเพิม DialogAPI กลับเข้า manifest + หน้า auth-start/auth-callback) · `signInFromDocument` (pass-through จาก token/owner ในไฟล์) เป็น mock-only — วันหน้า token ในไฟล์ควรเป็นแค่ตัวจับคู่เอกสาร ส่วนสิทธิ์ผู้ใช้พิสูจน์ตอน runtime
- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`
- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/`
## Docker
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ runner stage คัดลอก `public/` เข้า image เอง (`COPY /app/public ./public` — **Next 14.2 standalone ไม่รวม `public/` ให้อัตโนมัติ** ต้อง copy เองตามเอกสาร deploy ของ Next; เคยเข้าใจผิดว่า Next รวมให้ เพราะบางไฟล์หลุดเข้า image ผ่าน output file tracing ของ Next ที่ตาม `fs.readFile` ใน server code — แต่ `public/wordaddin/office-js` ไม่มีใครอ่านผ่าน fs จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile

317
docs/addin-ui-flow.md Normal file
View file

@ -0,0 +1,317 @@
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
---
## 1. State machine หลัก
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
```
checking ──(ไม่มี token)──────────────→ signed-out
│
├─(มี token + ไม่ใช่ tracked doc)──→ untracked
└─(มี token + tracked doc)────────→ tracking
```
การเปลี่ยน phase อื่นๆ:
- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out`
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ
State ที่ UI ถือนอกเหนือจาก phase:
| State | ความหมาย |
|---|---|
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) |
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
| `signingIn`, `loginError` | สถานะหน้า sign-in |
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
---
## 2. UI แต่ละ phase
### Header (ทุก phase)
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out`
### `checking`
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
### `signed-out`
- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning"
- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก"
- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable
- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow
> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน
### `untracked`
เรียงจากบนลงล่าง:
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
7. `flowError` → MessageBar **error**
### `tracking`
เรียงจากบนลงล่าง:
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
4. `flowError` → MessageBar **error**
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
---
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
- สถานะตามลำดับ:
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
- `saveError` → MessageBar **error**
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
พฤติกรรมปุ่ม:
| ปุ่ม | Flow |
|---|---|
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
---
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
| Prop | Save & Version | Start Versioning |
|---|---|---|
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
ภายใน dialog:
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
---
## 5. `VersionHistoryPanel` (FR-4)
- Header: icon history + "Version history" (semibold)
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
- `Badge v{versionNumber}` (สี brand)
- timestamp รูปแบบ medium date + short time ตาม locale
- `โดย {createdByUserId}`
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
---
## 6. Flow หลักพร้อมข้อความ progress
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่
1. ตรวจ token — ไม่มี → `signed-out`
2. อ่าน custom properties ครั้งเดียวทั้งชุด: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
### Start Versioning (FR-1) — จาก `untracked`
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
1. `กำลังบันทึก metadata ของเอกสาร…`
2. `กำลังอัปเดต version history ในไฟล์…`
3. `กำลังบันทึกไฟล์…`
4. `กำลังอ่านค่า fields จากเอกสาร…`
5. `กำลังอัปโหลด version 1…`
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
7. `กำลังอัปโหลด version…`
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
### Save & Version (FR-2) — จาก `tracking`
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
### Download version (FR-4/FR-7)
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
### Auto-save (FR-17)
- เริ่มตั้งแต่ mount **ไม่รอ sign-in**
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
---
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
multipart/form-data:
| field | type | หมายเหตุ |
|---|---|---|
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
### `GET /api/documents/{documentId}/versions` — history
response: `VersionRecord[]` เรียงใหม่ → เก่า:
```ts
interface VersionRecord {
versionId: string;
versionNumber: number;
createdAt: string; // ISO-8601
createdByUserId: string;
contentHash: string; // SHA256 hex
message: string | null; // commit message (FR-13)
hasPdf?: boolean;
fields: FieldsSnapshot | null;
}
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
```
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
### Auth — OIDC PKCE ผ่าน Office Dialog API
`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test
> **หมายเหตุสถานะปัจจุบัน (2026-10-14):** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — ทั้ง auth (แทนด้วย mock `wordaddin/services/authService.ts`) และ API ทั้งสามข้างบน (แทนด้วย in-memory mock `wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md`
---
## 8. กติกา error ที่ UI ต้องรองรับ
1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
---
## 9. ข้อควรระวังที่กระทบหน้าตา UI
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
---
## 10. แท็บ "เครื่องมือ Template" (2026-10-19)
เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน
### §1 ตรวจสอบ Content Controls
- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง
- สรุปหัวกลุ่ม: "N ช่อง"
- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag"
- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร")
### §2 กรอกค่าทดสอบ
- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-<index>`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ tag นั้น + ปุ่ม primary `เขียนค่าลงเอกสาร` (icon PenLine → "กำลังเขียนค่า…" ตอนรัน)
- **ช่องที่เว้นว่าง = ไม่ถูกแตะ** (กันเผลอล้างค่าจริง) · เขียนทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่องแบบ mail merge) · อ่าน id+tag สดรอบเดียวแล้วเขียน**แยก Word.run ราย tag** — tag ที่ Word ปฏิเสธ fail เฉพาะตัว เหลืออื่นยังเขียนได้ (ผลลัพธ์: `appliedCount` = จำนวนช่องที่เขียนจริง + `missingTags` ไม่พบช่อง + `failures` พร้อม code/errorLocation จาก OfficeExtension.Error) · UI รวมเป็น warning พร้อมชี้นำ "ถ้าเอกสารถูกล็อค (Review → Restrict Editing) ให้ปลดก่อน" — เคสจริงที่เจอ: error `0x800A16C6` บนเอกสารที่ `make-protected.mjs` ล็อคไว้ (readOnly + enforcement)
- ผลลัพธ์: success "เขียนค่าทดสอบลงแล้ว N ช่อง" (N = จำนวนช่องที่เขียนจริง — tag ซ้ำหลายช่องนับทุกช่อง) + warning missingTags ถ้ามี → **สแกนใหม่โดยคงค่าฟอร์ม** (เฉพาะ tag ที่ยังอยู่ในเอกสาร — tag ที่ถูกลบไปตัดทิ้ง) ให้แก้ต่อ · ปุ่ม disabled เมื่อยังไม่มีช่องที่กรอกค่าเลย · หลังเขียนสำเร็จ `notifyDocumentChanged()` ตั้ง debounce auto-save 3 วิ ส่วน banner FR-3 ตั้งเมื่อ periodic tick ถัดไปของ autoSaveService เห็น document dirty (พฤติกรรมเดิมของ service — ไม่ใช่ทันที)
### §3 แทรกย่อหน้า / ตัวแบ่ง (2026-10-19)
- 3 ปุ่มแนวตั้ง (icon บน label ล่าง, grid 3 คอลัมน์, `title` อธิบายเต็ม): **ย่อหน้า** (CornerDownLeft — `insertParagraph("", "After")` เหมือนกด Enter) · **แบ่งบรรทัด** (WrapText — `insertBreak("Line", "After")` เหมือน Shift+Enter จัดที่อยู่/รายการในย่อหน้าเดิม) · **แบ่งหน้า** (FilePlus — เหมือน Ctrl+Enter เอกสารหลายหน้า: `insertBreak("Page", "After")` ก่อน ถ้า host ปฏิเสธ E_FAIL 0x80004005 (บางบริบท เช่น cursor ในตาราง — เคสจริงบน T3 2026-10-19) สลับไป `insertOoxml` แบบ w:p สมบูรณ์ครอบ `<w:br w:type="page"/>` — fragment ต้องเป็น block-level เต็มตัว ใช้ run เดี่ยวก็ยัง E_FAIL)
- กดแล้วทำที่ cursor ทันที (ไม่มี success message — เห็นผลในเอกสารเลย) · icon สลับเป็น spinner ตอนรันตัวนั้น · ทุกปุ่ม disable ด้วย `busy` ร่วมกับเครื่องมืออื่น · fallback error: "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ"
### หมายเหตุ: ไม่มีปุ่มสลับ formatting marks (และไม่มีชุดแทรกอักขระพิเศษ)
- **Office.js ไม่มี API สลับแสดง/ซ่อน formatting marks** (¶ ท้ายย่อหน้า/จุดช่องว่าง) — เป็น application setting ของ Word ที่ WordApi ไม่เปิดให้ add-in แตะ ผู้ใช้กด **Ctrl+Shift+8** หรือปุ่ม **¶ บนแท็บ Home** ของ Word เอง (workflow จริงของผู้ใช้: แทรกย่อหน้าเพิ่ม → เปิด marks ตรวจ → ซ่อนกลับ — จึงมีปุ่มแทรกย่อหน้าให้ ส่วน marks ใช้ของ Word)
- เคยมีชุด "แทรกอักขระพิเศษ" (เลขไทย ๐–๙ + สัญลักษณ์) ต่อท้าย panel แล้ว**ถอดออก 2026-10-19** ตามคำขอผู้ใช้ — ที่ต้องการจริงคือ formatting marks ซึ่งทำผ่าน add-in ไม่ได้ (อ่านบันทึกเต็มใน git history รอบนั้น)
- เคยมีเครื่องมือ **"สร้างช่องจากข้อความที่เลือก"** (dialog ตั้ง tag + `insertContentControl` ครอบ selection + `validateTag` + `TagInputDialog` + unit test ของ validateTag) แล้ว**ถอดออกทั้งเส้น 2026-10-19** ตามคำขอผู้ใช้ — การสร้างช่องใหม่ทำใน Word ด้วย Developer tab แทน
### ข้อความ error กลางของ panel (MessageBar intent error)
- สแกนพัง: fallback "สแกนช่องในเอกสารไม่สำเร็จ" · ดูช่องพัง: "ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่" / fallback "เลือกช่องไม่สำเร็จ" · เขียนค่าพัง: fallback "เขียนค่าลงเอกสารไม่สำเร็จ" · แทรกพัง: fallback "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" (แบ่งหน้าที่พังทั้งสองวิธีจะรวมสาเหตุของทั้ง insertBreak และ OOXML fallback ในข้อความเดียว)
### พฤติกรรมใน browser ธรรมดา / `?addindemo`
ทุกปุ่มที่แตะเอกสาร (`สแกน`/`ดูช่อง`/`เขียนค่า`/`ย่อหน้า`/`แบ่งบรรทัด`/`แบ่งหน้า`) จบที่ error MessageBar ("Word is not defined") — ตามปรัชญา demo ของ repo เหมือนปุ่ม Save & Version ส่วน toggle/สลับแท็บ/ฟอร์มค่าทดสอบ ทดสอบได้ครบใน browser

View file

@ -0,0 +1,29 @@
CREATE TABLE "document_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" uuid NOT NULL,
"version_number" integer NOT NULL,
"client_version_id" text,
"content_hash" text NOT NULL,
"message" text,
"fields" jsonb,
"template_id" text,
"file_name" text,
"docx_object_key" text NOT NULL,
"pdf_object_key" text,
"docx_size" integer NOT NULL,
"pdf_size" integer,
"user_id" text NOT NULL,
"user_name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"doc_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");

View file

@ -0,0 +1,735 @@
{
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.annotation_replies": {
"name": "annotation_replies",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"annotation_id": {
"name": "annotation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"annotation_replies_annotation_idx": {
"name": "annotation_replies_annotation_idx",
"columns": [
{
"expression": "annotation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotation_replies_annotation_id_annotations_id_fk": {
"name": "annotation_replies_annotation_id_annotations_id_fk",
"tableFrom": "annotation_replies",
"tableTo": "annotations",
"columnsFrom": [
"annotation_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.annotation_versions": {
"name": "annotation_versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"annotation_id": {
"name": "annotation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "annotation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"annotation_versions_file_idx": {
"name": "annotation_versions_file_idx",
"columns": [
{
"expression": "file_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotation_versions_file_id_files_id_fk": {
"name": "annotation_versions_file_id_files_id_fk",
"tableFrom": "annotation_versions",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.annotations": {
"name": "annotations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "annotation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved": {
"name": "resolved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"annotations_file_page_idx": {
"name": "annotations_file_page_idx",
"columns": [
{
"expression": "file_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "page_index",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"annotations_file_id_files_id_fk": {
"name": "annotations_file_id_files_id_fk",
"tableFrom": "annotations",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.bookmarks": {
"name": "bookmarks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"file_id": {
"name": "file_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_index": {
"name": "page_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
},
"y_offset": {
"name": "y_offset",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"bookmarks_file_id_files_id_fk": {
"name": "bookmarks_file_id_files_id_fk",
"tableFrom": "bookmarks",
"tableTo": "files",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.document_versions": {
"name": "document_versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"document_id": {
"name": "document_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version_number": {
"name": "version_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"client_version_id": {
"name": "client_version_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fields": {
"name": "fields",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"docx_object_key": {
"name": "docx_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"pdf_object_key": {
"name": "pdf_object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"docx_size": {
"name": "docx_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"pdf_size": {
"name": "pdf_size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"document_versions_document_number_key": {
"name": "document_versions_document_number_key",
"columns": [
{
"expression": "document_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "version_number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"document_versions_document_created_idx": {
"name": "document_versions_document_created_idx",
"columns": [
{
"expression": "document_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"document_versions_document_id_documents_id_fk": {
"name": "document_versions_document_id_documents_id_fk",
"tableFrom": "document_versions",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"doc_id": {
"name": "doc_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"documents_doc_id_key": {
"name": "documents_doc_id_key",
"columns": [
{
"expression": "doc_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.files": {
"name": "files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type_id": {
"name": "type_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"files_type_id_idx": {
"name": "files_type_id_idx",
"columns": [
{
"expression": "type_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"files_external_id_key": {
"name": "files_external_id_key",
"columns": [
{
"expression": "external_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.annotation_type": {
"name": "annotation_type",
"schema": "public",
"values": [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -36,6 +36,13 @@
"when": 1790072946313,
"tag": "0004_right_ted_forrester",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1792467537332,
"tag": "0005_icy_bloodaxe",
"breakpoints": true
}
]
}

227
lib/document-versioning.ts Normal file
View file

@ -0,0 +1,227 @@
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
import { decodeUploadFilename } from "@/utils/upload-name";
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
export const DOCVER_MESSAGE_MAX = 2048;
export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const PDF_CONTENT_TYPE = "application/pdf";
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
const FORM_FIELDS = [
"file",
"pdf",
"clientVersionId",
"fields",
"message",
"fileName",
] as const;
export type DocverError = { ok: false; error: string };
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
export function docverError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
export function isDocxBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
}
export function isPdfBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
}
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
export function parseVersionIdParam(raw: string):
| { ok: true; versionId: string }
| DocverError {
const versionId = raw.trim().toLowerCase();
if (!UUID_RE.test(versionId)) {
return { ok: false, error: "versionId ต้องเป็น UUID" };
}
return { ok: true, versionId };
}
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
export function parseDownloadFormat(raw: string | null):
| { ok: true; format: "docx" | "pdf" }
| DocverError {
const format = raw?.trim() ?? "";
if (format === "") return { ok: true, format: "docx" };
if (format === "docx" || format === "pdf") return { ok: true, format };
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
}
function trimOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
function formString(form: FormData, field: string):
| { ok: true; value: string | null }
| DocverError {
const entry = form.get(field);
if (entry === null) return { ok: true, value: null };
if (typeof entry !== "string") {
return { ok: false, error: `field '${field}' ต้องเป็น string` };
}
return { ok: true, value: trimOrNull(entry) };
}
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
export function sanitizeDisplayFileName(raw: string | null): string | null {
if (raw === null) return null;
const base = raw.split(/[\\/]/).pop() ?? raw;
return trimOrNull(base)?.slice(0, 200) ?? null;
}
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
export function sanitizeFilePartName(raw: string): string | null {
return sanitizeDisplayFileName(decodeUploadFilename(raw));
}
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
export function parseFieldsJson(raw: string):
| { ok: true; fields: Record<string, string> | null }
| DocverError {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
const record = parsed as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length > 100) {
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
}
for (const key of keys) {
if (key.length > 200) {
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
}
const value = record[key];
if (typeof value !== "string") {
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
}
if (value.length > 10_000) {
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
}
}
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
}
export type ParsedVersionUploadForm = {
clientVersionId: string | null;
message: string | null;
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
fileName: string | null;
fields: Record<string, string> | null;
};
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
export function parseVersionUploadForm(form: FormData):
| { ok: true; form: ParsedVersionUploadForm }
| DocverError {
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
// downlevelIteration — array ธรรมดาไม่ต้อง)
for (const key of Array.from(form.keys())) {
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
};
}
}
const clientVersionId = formString(form, "clientVersionId");
if (!clientVersionId.ok) return clientVersionId;
if (clientVersionId.value && clientVersionId.value.length > 100) {
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
}
const message = formString(form, "message");
if (!message.ok) return message;
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
}
const rawFields = formString(form, "fields");
if (!rawFields.ok) return rawFields;
let fields: Record<string, string> | null = null;
if (rawFields.value !== null) {
const parsed = parseFieldsJson(rawFields.value);
if (!parsed.ok) return parsed;
fields = parsed.fields;
}
const rawFileName = formString(form, "fileName");
if (!rawFileName.ok) return rawFileName;
return {
ok: true,
form: {
clientVersionId: clientVersionId.value,
message: message.value,
fileName: sanitizeDisplayFileName(rawFileName.value),
fields,
},
};
}
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
const ext = `.${kind}`;
const ascii = (displayName ?? "")
.replace(/[^\x20-\x7E]/g, "")
.replace(/["\\]/g, "")
.replace(/\s+/g, " ")
.trim();
let asciiName: string = DEFAULT_FILE_NAMES[kind];
if (/[A-Za-z0-9]/.test(ascii)) {
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
}
const display = displayName?.trim();
if (display) {
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
}
return `attachment; filename="${asciiName}"`;
}

275
lib/docver-store.ts Normal file
View file

@ -0,0 +1,275 @@
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
import { and, desc, eq, sql } from "drizzle-orm";
import { Client } from "minio";
import { db } from "@/db";
import {
documents,
documentVersions,
type DocumentRow,
type DocumentVersionRow,
} from "@/db/schema";
import {
contentDispositionValue,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "@/lib/document-versioning";
type MinioConfig = {
endPoint: string;
port: number;
useSSL: boolean;
accessKey: string;
secretKey: string;
bucket: string;
ttlSeconds: number;
};
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
function readConfig(): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT?.trim();
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
if (!endPoint || !accessKey || !secretKey) {
throw new Error(
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
);
}
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
return {
endPoint,
port: Number.isFinite(port) && port > 0 ? port : 9000,
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
accessKey,
secretKey,
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
ttlSeconds:
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
};
}
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
const globalForMinio = globalThis as unknown as {
docverClient?: { key: string; client: Client };
docverBucketReady?: { bucket: string; promise: Promise<void> };
};
function getClient(): { client: Client; config: MinioConfig } {
const config = readConfig();
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
if (globalForMinio.docverClient?.key !== key) {
globalForMinio.docverClient = {
key,
client: new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
}),
};
}
return { client: globalForMinio.docverClient.client, config };
}
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
async function ensureBucket(client: Client, bucket: string): Promise<void> {
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
const promise = (async () => {
if (await client.bucketExists(bucket)) return;
try {
await client.makeBucket(bucket);
} catch (err) {
if (!(await client.bucketExists(bucket))) throw err;
}
})();
globalForMinio.docverBucketReady = { bucket, promise };
promise.catch(() => {
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
if (globalForMinio.docverBucketReady?.promise === promise) {
globalForMinio.docverBucketReady = undefined;
}
});
}
return globalForMinio.docverBucketReady.promise;
}
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
return {
docx: `docver/${versionId}/document.docx`,
pdf: `docver/${versionId}/document.pdf`,
};
}
export type PutVersionInput = {
versionId: string;
docx: { bytes: Buffer; fileName: string | null };
pdf?: { bytes: Buffer; fileName: string | null };
};
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
const { client, config } = getClient();
await ensureBucket(client, config.bucket);
const keys = versionObjectKeys(input.versionId);
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
"Content-Type": DOCX_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
});
if (!input.pdf) return;
try {
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
"Content-Type": PDF_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
});
} catch (err) {
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
throw err;
}
}
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
export async function removeVersionObjects(
versionId: string,
hadPdf: boolean,
): Promise<void> {
try {
const { client, config } = getClient();
const keys = versionObjectKeys(versionId);
await client.removeObject(config.bucket, keys.docx);
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
} catch (err) {
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
}
}
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
* คำนวณ local ล้วนไม่มี network call */
export async function presignVersionUrl(
versionId: string,
format: "docx" | "pdf",
): Promise<{ url: string; expiresAt: string }> {
const { client, config } = getClient();
const key = versionObjectKeys(versionId)[format];
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
return {
url,
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
};
}
// ─── DB ──────────────────────────────────────────────────────────────────────
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
return row ?? null;
}
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
return db
.select()
.from(documentVersions)
.where(eq(documentVersions.documentId, documentId))
.orderBy(desc(documentVersions.versionNumber));
}
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
export async function findVersionForDoc(
docId: string,
versionId: string,
): Promise<DocumentVersionRow | null> {
const [row] = await db
.select({ version: documentVersions })
.from(documentVersions)
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
.limit(1);
return row?.version ?? null;
}
export type InsertVersionInput = {
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
versionId: string;
docId: string;
clientVersionId: string | null;
contentHash: string;
message: string | null;
fields: Record<string, string> | null;
templateId: string | null;
fileName: string | null;
docxObjectKey: string;
pdfObjectKey: string | null;
docxSize: number;
pdfSize: number | null;
userId: string;
userName: string;
};
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
return db.transaction(async (tx) => {
await tx
.insert(documents)
.values({ docId: input.docId })
.onConflictDoNothing({ target: documents.docId });
const [doc] = await tx
.select({ id: documents.id })
.from(documents)
.where(eq(documents.docId, input.docId))
.for("update");
if (!doc) {
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
}
const [latest] = await tx
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
.from(documentVersions)
.where(eq(documentVersions.documentId, doc.id));
const [row] = await tx
.insert(documentVersions)
.values({
id: input.versionId,
documentId: doc.id,
versionNumber: (latest?.max ?? 0) + 1,
clientVersionId: input.clientVersionId,
contentHash: input.contentHash,
message: input.message,
fields: input.fields,
templateId: input.templateId,
fileName: input.fileName,
docxObjectKey: input.docxObjectKey,
pdfObjectKey: input.pdfObjectKey,
docxSize: input.docxSize,
pdfSize: input.pdfSize,
userId: input.userId,
userName: input.userName,
})
.returning();
return row!;
});
}

223
lib/docver-token.ts Normal file
View file

@ -0,0 +1,223 @@
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { docverError, type DocverError } from "@/lib/document-versioning";
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
export type DocverTokenClaims = {
userid: string;
username: string;
fid: string;
template: string | null;
};
function base64url(input: string): string {
return Buffer.from(input, "utf8").toString("base64url");
}
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
let text: string;
try {
text = Buffer.from(segment, "base64url").toString("utf8");
} catch {
return null;
}
try {
const parsed = JSON.parse(text) as unknown;
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
export function issueDocverToken(
claims: DocverTokenClaims,
secret: string,
ttlSeconds: number,
): string {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const iat = Math.floor(Date.now() / 1000);
const payload = base64url(
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
function claimsFromPayload(
payload: Record<string, unknown>,
): DocverTokenClaims | null {
const { userid, username, fid, template } = payload;
if (typeof userid !== "string" || userid === "") return null;
if (typeof username !== "string" || username === "") return null;
if (typeof fid !== "string" || fid === "") return null;
if (template !== null && typeof template !== "string") return null;
return { userid, username, fid, template };
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
const segments = token.split(".");
if (segments.length !== 3) return { ok: false };
const [headerSegment, payloadSegment, signatureSegment] = segments;
const header = decodeJsonSegment(headerSegment);
if (header === null || header.alg !== "HS256") return { ok: false };
const expected = createHmac("sha256", secret)
.update(`${headerSegment}.${payloadSegment}`)
.digest();
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
const provided = createHash("sha256")
.update(Buffer.from(signatureSegment, "base64url"))
.digest();
const expectedHash = createHash("sha256").update(expected).digest();
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
const payload = decodeJsonSegment(payloadSegment);
if (payload === null) return { ok: false };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
const claims = claimsFromPayload(payload);
if (claims === null) return { ok: false };
return { ok: true, claims };
}
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKey = process.env.DOCVER_API_KEY?.trim();
// const envKey =
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
if (envKey === undefined || envKey === "") {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
}
const provided = request.headers.get("x-api-key");
if (provided === null || provided === "") {
return docverError("API key ไม่ถูกต้อง", 401);
}
const providedHash = createHash("sha256").update(provided).digest();
const envHash = createHash("sha256").update(envKey).digest();
return timingSafeEqual(providedHash, envHash)
? null
: docverError("API key ไม่ถูกต้อง", 401);
}
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
export function requireDocverBearer(
request: Request,
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization");
if (header === null) return denied();
const match = /^Bearer\s+(\S+)$/i.exec(header);
if (match === null) return denied();
const verified = verifyDocverToken(match[1], secret);
if (!verified.ok) return denied();
return { ok: true, claims: verified.claims };
}
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
export function parseTokenRequestBody(
raw: unknown,
): { ok: true; claims: DocverTokenClaims } | DocverError {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
};
}
}
const rawUserid = record.userid;
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
return { ok: false, error: "ต้องระบุ userid (string)" };
}
const userid = rawUserid.trim();
if (userid.length > 100) {
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
}
const rawUsername = record.username;
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
return { ok: false, error: "ต้องระบุ username (string)" };
}
const username = rawUsername.trim();
if (username.length > 200) {
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
}
const rawFid = record.fid;
if (typeof rawFid !== "string" || rawFid.trim() === "") {
return { ok: false, error: "ต้องระบุ fid (string)" };
}
const fid = rawFid.trim();
if (fid.length > 200) {
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
}
const rawTemplate = record.template ?? null;
if (rawTemplate !== null && typeof rawTemplate !== "string") {
return { ok: false, error: "template ต้องเป็น string" };
}
const template = rawTemplate?.trim() || null;
if (template !== null && template.length > 100) {
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
}
return { ok: true, claims: { userid, username, fid, template } };
}

1263
lib/lib-tmpl.ts Executable file

File diff suppressed because it is too large Load diff

112
lib/lib-utils.ts Normal file
View file

@ -0,0 +1,112 @@
import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from 'node:crypto';
/**
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
*/
export function generateHmacSha256(data: string | object, secretKey: string): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
return createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
}
/**
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
*/
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
const calculatedHmac = generateHmacSha256(data, secretKey);
const a = Buffer.from(calculatedHmac, 'hex');
const b = Buffer.from(expectedHmac, 'hex');
if (a.length !== b.length) {
return false;
}
return timingSafeEqual(a, b);
}
// ==========================================
// ตัวอย่างการใช้งาน
// ==========================================
/*
const secret = 'my-super-secret-key';
const jsonData = {
userId: 12345,
action: 'transfer',
amount: 500
};
// 1. สร้าง HMAC
const hmacSignature = generateHmacSha256(jsonData, secret);
console.log('HMAC Signature (Hex):', hmacSignature);
// 2. ตรวจสอบ HMAC
const isValid = verifyHmacSha256(jsonData, secret, hmacSignature);
console.log('Is Signature Valid?:', isValid); // true
*/
/**
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign) ข้อมูลด้วย Ed25519 Private Key (Hex)
* @param message ข้อความดิบที่ต้องการเซ็น (string หรือ Buffer)
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
*/
export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
// PKCS#8 Header สำหรับ Ed25519 Private Key
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]);
const privateKey = createPrivateKey({
key: pkcs8Buffer,
format: 'der',
type: 'pkcs8',
});
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
const signatureBuffer = sign(null, dataBuffer, privateKey);
return signatureBuffer.toString('hex');
}
/**
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
* @param message ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
* @param signatureHex ลายเซ็นในรูปแบบ Hex String
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
*/
export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
try {
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
// SPKI Header สำหรับ Ed25519 Public Key (32 bytes)
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
const spkiBuffer = Buffer.concat([spkiHeader, rawPublicKey]);
const publicKey = createPublicKey({
key: spkiBuffer,
format: 'der',
type: 'spki',
});
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
const signatureBuffer = Buffer.from(signatureHex, 'hex');
return verify(null, dataBuffer, publicKey, signatureBuffer);
} catch (error) {
return false;
}
}
/**
* ฟังก์ชันสำหรับเรียงคีย์ของ Object ตามตัวอักษรเพื่อไม่ให้โครงสร้าง JSON เพี้ยน
*/
export function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
return Object.keys(obj)
.sort()
.reduce((result: Record<string, unknown>, key: string) => {
result[key] = obj[key];
return result;
}, {});
}

View file

@ -1,5 +1,6 @@
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API
// แสดงผล: กลุ่ม 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 แยก)
//
@ -23,16 +24,25 @@ export const openApiSpec = {
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 ทดสอบของระบบ 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) " +
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
"Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ",
},
{
name: "documents",
description:
"จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " +
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " +
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
"และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " +
"ไม่รับจาก URL/multipart form อีก",
},
{
name: "Judge's Note",
@ -64,53 +74,15 @@ export const openApiSpec = {
},
},
},
"/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)",
summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น",
description:
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)',
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)<br/>'+
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ</i>',
requestBody: {
required: true,
content: {
@ -132,9 +104,23 @@ export const openApiSpec = {
dataJson: {
type: "string",
description:
"JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)",
"JSON เป็น string ตรง ๆ (ทางเลือกแทนการแนบไฟล์ data)<br/>{\"info:\"{\"ticket_data\":\"JSON String\"},\"dc:\"{..},\"dc:\"{\"blackyear\": \"๙๐/๒๕๖๙\",...}}",
example:
'{"notname":"คำสั่งเรียก ให้ทำคำให้การ","blackno":"คดีหมายเลขดำที่","blackyear":"๑๐/๒๕๖๙"}',
'',
},
secret: {
type: "string",
description:
"ค่า secret สำหรับทดสอบการทำ HMAC-SHA256",
example:
'super_secret',
},
private_key: {
type: "string",
description:
"Private key ในรูปแบบ HEX <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex",
example:
'8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1',
},
},
},
@ -155,6 +141,410 @@ export const openApiSpec = {
},
},
},
"/api/template/extract": {
post: {
tags: ["template"],
summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx ใช้สำหรับนักพัฒนาเท่านั้น",
description:
"อ่าน Content Control, bookmark , property ของ MS Word(docx) ออกมาแสดง" +
"ค่าของ Content Control จะเป็น Plain Text ไม่มี markup ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ</i>',
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 <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>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/generate": {
post: {
tags: ["template"],
summary: "สร้างเอกสาร DOCX จากข้อมูล JSON และ template ที่กำหนด",
description:
"เทมเพลตมีแท็กแบบพิเศษ(รองรับตัวแปร ลูป เงื่อนไข), Content Control เมื่อรวมกับข้อมูล(JSON) "+
"จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล<br><ul>"+
"<li>JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)</li>" +
"<li>ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร </li>" +
"<li>ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning</li>"+
"</ul>",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["info"],
properties: {
info: {
type: "object",
required: ["ticket_data"],
properties: {
ticket_data: {
type: "string",
description:
"JSON string ที่ต้อง parse ได้ และต้องมี tid, fid และ uid",
example:
'{"tid":"T12","fid":"DOC-2569-0001","uid":"123"}',
},
},
additionalProperties: true,
},
},
additionalProperties: true,
},
example: {
"info": {
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
},
"dc": {
"Interpleader": true,
"docname": "",
"docabbr": "(ต. ๑๒)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"panels": [
{
"pname": "นายวิรุฬห์ จินตนะกุล",
"pmname": "ตุลาการศาลปกครองกลาง",
"pm": "ตุลาการเจ้าของสำนวน"
},
{
"pname": "นายสัมฤทธิ์ อ่อนคำ",
"pmname": "ตุลาการศาลปกครองสูงสุด"
}
],
"pnameT": "",
"pmnameT": "",
"pmT": "",
"acc_name": ""
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
}
},
},
},
},
responses: {
200: {
description: "ไฟล์ DOCX ที่สร้างสำเร็จ",
headers: {
"Content-Disposition": {
description: "ชื่อไฟล์สำหรับดาวน์โหลด",
schema: { type: "string" },
},
"Cache-Control": {
description: "ป้องกันการ cache response",
schema: { type: "string", example: "no-store, max-age=0" },
},
},
content: {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
schema: { type: "string", format: "binary" },
},
},
},
400: {
description:
"JSON ไม่มี info.ticket_data, ticket_data ไม่ใช่ string, JSON string ไม่ถูกต้อง หรือไม่มี tid/fid/uid",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
examples: {
missingTicketData: {
value: {
error:
"template/generate json.info.ticket_data not avaliable",
},
},
missingIdentifiers: {
value: {
error:
"template/generate require json.info.ticket_data.(fid|tid|uid)",
},
},
},
},
},
},
401: {
description: "ไม่ได้ส่ง x-api-key หรือ API key ไม่ถูกต้อง",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Unauthorized: Invalid API Key" },
},
},
},
404: {
description: "ไม่สามารถดึงข้อมูลขึ้นมาได้",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "NotFound: Invalid template ID or incorrect storage path" },
},
},
},
500: {
description:
"เกิดข้อผิดพลาดระหว่างอ่าน template หรือสร้างเอกสาร",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Failed to generate document payload" },
},
},
},
},
},
},
"/api/template/download-docx": {
get: {
tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง",
description:
"รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
parameters: [
{
name: "fid",
in: "query",
required: true,
schema: { type: "string" },
description: "ID เอกสาร (File ID)",
example: "T_no_warning",
},
{
name: "tid",
in: "query",
required: true,
schema: { type: "string" },
description: "ID เทมเพลต (Template ID)",
example: "T_X",
},
{
name: "uid",
in: "query",
required: true,
schema: { type: "string" },
description: "รหัสผู้ใช้งาน (User ID)",
example: "123",
},
{
name: "uname",
in: "query",
required: true,
schema: { type: "string" },
description: "ชื่อผู้ใช้งาน (User Name)",
example: "สมชาย ใจดี",
},
],
responses: {
200: {
description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ",
headers: {
"Content-Disposition": {
description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)",
schema: { type: "string", example: 'attachment; filename="T_no_warning_123_xxx.docx"' },
},
"Cache-Control": {
description: "การควบคุม Cache (no-store)",
schema: { type: "string", example: "no-store, max-age=0" },
},
},
content: {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
schema: { type: "string", format: "binary" },
},
},
},
400: {
description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง",
content: {
"text/plain": {
schema: { type: "string" },
examples: {
missingParams: { value: "Missing fid,tid,uid or uname" },
invalidData: { value: "Invalid data format" },
},
},
},
},
404: {
description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Data not found: /path/to/TMPL_PATH/T_no_warning.json" },
},
},
},
502: {
description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate",
content: {
"text/plain": {
schema: { type: "string" },
example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)",
},
},
},
500: {
description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
content: {
"text/plain": {
schema: { type: "string" },
example: "Internal Server Error",
},
},
},
},
},
post: {
tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง",
description:
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+
"จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["fid", "tid", "uid", "uname"],
properties: {
fid: { type: "string", description: "ID เอกสาร (File ID)",example:"T12_b2_IN" },
tid: { type: "string", description: "ID เทมเพลต (Template ID)",example:"T12_X" },
uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)", example:"john" },
uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)", example:"John Doe"},
},
},
},
"application/x-www-form-urlencoded": {
schema: {
type: "object",
required: ["fid", "tid", "uid", "uname"],
properties: {
fid: { type: "string", description: "ID เอกสาร (File ID)" },
tid: { type: "string", description: "ID เทมเพลต (Template ID)" },
uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)" },
uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)" },
},
},
},
},
},
responses: {
200: {
description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ",
headers: {
"Content-Disposition": {
description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)",
schema: { type: "string", example: 'attachment; filename="T12_b2_IN_john_xxx.docx"' },
},
"Cache-Control": {
description: "การควบคุม Cache (no-store)",
schema: { type: "string", example: "no-store, max-age=0" },
},
},
content: {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
schema: { type: "string", format: "binary" },
},
},
},
400: {
description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง",
content: {
"text/plain": {
schema: { type: "string" },
examples: {
missingParams: { value: "Missing fid,tid,uid or uname" },
invalidData: { value: "Invalid data format" },
},
},
},
},
404: {
description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Data not found: /path/to/TMPL_PATH/T12_b2_IN.json" },
},
},
},
502: {
description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate",
content: {
"text/plain": {
schema: { type: "string" },
example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)",
},
},
},
500: {
description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
content: {
"text/plain": {
schema: { type: "string" },
example: "Internal Server Error",
},
},
},
},
},
},
"/api/note/external/documents": {
post: {
tags: ["external"],
@ -217,6 +607,220 @@ export const openApiSpec = {
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],
summary: "ขอ access_token ของกลุ่ม documents (docver)",
description:
"ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " +
"JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " +
"DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " +
"templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " +
"fid จึงบังคับ",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenRequest" },
examples: {
"ครบทุก claim": {
value: {
userid: "123",
username: "Sorawit Bholsithi",
fid: "3",
template: "T12",
},
},
"ขั้นต่ำ (template เป็นทางเลือก)": {
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
},
},
},
},
},
responses: {
200: {
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/versions": {
post: {
tags: ["documents"],
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " +
"อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " +
"พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " +
"ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " +
"สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " +
"แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)",
security: [{ DocverBearer: [] }],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["file"],
properties: {
file: {
type: "string",
format: "binary",
description: "ไฟล์ .docx ของ version นี้ (บังคับ)",
},
pdf: {
type: "string",
format: "binary",
description:
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
},
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 ตัวอักษร)",
},
fileName: {
type: "string",
description:
"ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)",
},
},
},
},
},
},
responses: {
200: {
description: "version ถูกบันทึกแล้ว",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UploadVersionResult" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
413: {
description: "ไฟล์แนบใหญ่เกิน 100MB",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
get: {
tags: ["documents"],
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " +
"ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " +
"และ hasPdf — ใช้เขียน UI version history ของ add-in",
security: [{ DocverBearer: [] }],
responses: {
200: {
description: "รายการ version เรียงใหม่ → เก่า",
content: {
"application/json": {
schema: {
type: "array",
items: { $ref: "#/components/schemas/VersionRecord" },
},
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
404: {
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/versions/{versionId}/download-link": {
get: {
tags: ["documents"],
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " +
"(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " +
"ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " +
"ใน version ที่ไม่มีสำเนา = 404",
security: [{ DocverBearer: [] }],
parameters: [
{
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" },
401: { $ref: "#/components/responses/DocverUnauthorized" },
404: {
description:
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
},
components: {
securitySchemes: {
@ -225,7 +829,17 @@ export const openApiSpec = {
in: "header",
name: "x-api-key",
description:
"ค่าเดียวกับ env EXTERNAL_API_KEY ของ server — กดปุ่ม Authorize ด้านบนเพื่อใส่ครั้งเดียวแล้วใช้ทุก request",
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง)",
},
DocverBearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " +
"กับ endpoint อื่นของกลุ่ม documents ทุกตัว",
},
},
responses: {
@ -239,6 +853,15 @@ export const openApiSpec = {
},
},
},
DocverUnauthorized: {
description:
"ไม่ได้แนบ Authorization: Bearer หรือ token ไม่ถูกต้อง/หมดอายุ (กลุ่ม documents — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
ServerError: {
description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)",
content: {
@ -252,21 +875,82 @@ export const openApiSpec = {
Error: {
type: "object",
properties: {
success: { type: "boolean", example: false },
error: { type: "string" },
},
},
TokenRequest: {
type: "object",
description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400",
required: ["userid", "username", "fid"],
properties: {
userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" },
username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" },
fid: {
type: "string",
description:
"fileId ของ e-Court (บังคับ, ≤ 200 ตัวอักษร) — เป็น docId ของทุก operation ตรง ๆ",
},
template: {
type: "string",
nullable: true,
description:
"รหัส template ที่ใช้สร้างเอกสาร — กลายเป็น templateId ตอนอัปโหลด version",
},
},
},
TokenResponse: {
type: "object",
description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)",
properties: {
access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" },
token_type: { type: "string", example: "Bearer" },
expires_in: {
type: "integer",
description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)",
},
},
},
ExtractResponse: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: {
type: "object",
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
additionalProperties: { type: "string" },
example: {
blackno: "คดีหมายเลขดำที่",
court: "ศาลปกครองสุพรรณบุรี",
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"accuser": "นายธีรพล ร่มเริง",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง...",
},
"bookmark": {
"signature": ""
},
"properties": {
"standard": {
"creator": "mam",
"created": "2026-10-22T02:58:00.000Z",
"modified": "2026-10-22T07:08:00.000Z"
},
"extended": {
"template": "TS_P2_IN.dotx",
"totalTime": 172,
"pages": 2,
"words": 124,
},
"custom": {
"ticket_data": "{\"exp\":1793013726,\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}",
"HmacSha256": "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
"Ed25519": "95ea0ca6d81df885c22a2334ed89b2fc3c75a1a309fb5843e0deb0a16c9d05a45b345fb28fd606f3b152285e70560ad43a6389534794aa0d6f06d13f1307b005"
}
},
"verify": {
"exp": "26/10/2569 18:22:06",
"HmacSha256": true,
"Ed25519": true
}
},
},
},
@ -407,6 +1091,48 @@ export const openApiSpec = {
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;

View file

@ -23,13 +23,20 @@ export function middleware(req: NextRequest) {
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
// ไฟล์อัปโหลด, Next internal, และ static ใน public/note/ (pdf.worker, samples,
// asset Swagger UI) + public/ ราก (fonts, favicon)
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
// + public/ ราก (fonts, favicon)
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
export const config = {
matcher: [
"/((?!note/login|api/note|api/template|api/swagger|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
"/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
],
};

View file

@ -14,10 +14,24 @@ const nextConfig = {
basePath,
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
env: { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_APP_PATH: appPath },
// เข้า /docedit/ ตรง ๆ (ไม่มี route ที่ root) → โผล่ที่หน้ารายการของ /note
// (Next เติม basePath ให้ source/destination เอง + query ส่งต่ออัตโนมัติ — รองรับ /docedit/?fileUrl=…)
// ปิด redirect trailing slash อัตโนมัติของ Next (308 /docedit → /docedit/
// และ /docedit/note/ → /docedit/note) — deploy หลัง IIS แล้ว redirect พวกนี้
// ตีกับ rewrite rule ของ IIS จนกลายเป็น ping-pong loop; ปิดให้หน้า/ไฟล์ทุกอย่าง
// serve ได้ทั้งแบบมี/ไม่มี slash ฝั่ง proxy เป็นคนกำหนดเอง
skipTrailingSlashRedirect: true,
// จับเฉพาะ root ของ host ("/" แท้ ๆ — basePath:false ทำให้ rule นี้ไม่จับ
// /docedit) เผื่อ IIS ส่ง path "/" เข้า process: ส่งตรงไป /docedit/note เลย
// ส่วน /docedit และ /docedit/ จับที่ app/page.tsx แทน (ส่งต่อ query เอง —
// รักษาสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId=)
async redirects() {
return [{ source: "/", destination: appPath, permanent: false }];
return [
{
source: "/",
destination: `${basePath}${appPath}`,
basePath: false, // ป้องกัน basePath ซ้อน (destination เขียนเต็มแล้ว)
permanent: false,
},
];
},
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
experimental: {

8337
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,13 +8,17 @@
"test": "vitest run",
"start": "next start",
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs"
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
"dev:https": "next dev --experimental-https",
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
},
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",
"@xmldom/xmldom": "^0.9.12",
"docx-templates": "^4.15.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.42.0",
"minio": "^8.0.7",
"next": "^14.2.35",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
@ -28,13 +32,16 @@
"xpath": "^0.0.34"
},
"devDependencies": {
"@types/node": "^26.5.0",
"@types/node": "^26.6.5",
"@types/office-js": "^1.0.612",
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"office-addin-debugging": "^7.0.1",
"office-addin-dev-settings": "^4.0.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",

7543
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,6 @@
allowBuilds:
'@azure/msal-node-extensions': set this to true or false
'@microsoft/m365agentstoolkit-cli': set this to true or false
'@scarf/scarf': set this to true or false
esbuild: set this to true or false
keytar: set this to true or false

File diff suppressed because one or more lines are too long

BIN
app/api/template/test/T3.docx → public/tmpl/T12_X.docx Normal file → Executable file

Binary file not shown.

View file

@ -0,0 +1,41 @@
{
"info": {
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
},
"dc": {
"Interpleader": true,
"docname": "",
"docabbr": "(ต. ๑๒)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"panels": [
{
"pname": "นายวิรุฬห์ จินตนะกุล",
"pmname": "ตุลาการศาลปกครองกลาง",
"pm": "ตุลาการเจ้าของสำนวน"
},
{
"pname": "นายสัมฤทธิ์ อ่อนคำ",
"pmname": "ตุลาการศาลปกครองสูงสุด"
}
],
"pnameT":"",
"pmnameT":"",
"pmT":"",
"acc_name":""
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง"
}
}

View file

@ -0,0 +1,50 @@
{
"info": {
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
},
"dc": {
"Interpleader": false,
"CommandTo": true,
"SendTo": false,
"AdditionCommand" : true,
"Warning":false,
"docname":"คำสั่งศาล",
"doctype":"(ต. ๔๗.๑)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"doctitle":"คำสั่งศาลถึง",
"pname":"สมหญิง",
"courtname":"ศาลปกครองสุพรรณบุรี",
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
"pname2":"ชื่อของ pname2"
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"commto" : "นายสมชาย จรดปลายเท้า",
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
"groupname":"ผู้อำนวยการโรงเรียน",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
"remarkhead":"หัวของ Remark",
"remark":"This is remark Text bla bla "
}
}

52
public/tmpl/TS_P2_IN.json Executable file
View file

@ -0,0 +1,52 @@
{
"info": {
"ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
},
"dc": {
"Interpleader": true,
"CommandTo": true,
"SendTo": false,
"AdditionCommand" : true,
"Warning":true,
"docname":"คำสั่งเรียกพยานบุคคล",
"doctype":"(ต. ๖)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"doctitle":"คำสั่งถึง",
"pname":"สมหญิง",
"courtname":"ศาลปกครองสุพรรณบุรี",
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
"pname2":"ชื่อของ pname2",
"remarkhead":"หัวของ Remark"
},
"cc": {
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"commto" : "นายสมชาย จรดปลายเท้า",
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
"groupname":"ผู้อำนวยการโรงเรียน",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
"remark":"This is remark Text bla bla "
}
}

Binary file not shown.

51
public/tmpl/T_no_warning.json Executable file
View file

@ -0,0 +1,51 @@
{
"info": {
"ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
},
"dc": {
"Interpleader": false,
"CommandTo": true,
"SendTo": true,
"AdditionCommand" : false,
"Warning":false,
"docname":"คำสั่งเรียก\nให้ทำคำให้การเพิ่มเติม",
"doctype":"(ต. ๓)",
"blackno": "คดีหมายเลขดำที่",
"redno": "คดีหมายเลขแดงที่",
"court": "ศาลปกครองนครราชสีมา",
"doctitle":"คำสั่งถึง",
"pname":"สมหญิง",
"courtname":"ศาลปกครองสุพรรณบุรี",
"courtno":"๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี",
"courttam":"จังหวัดสุพรรณบุรี ๗๒๐๐๐",
"courttel":"กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓",
"courttel2":"สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน ๑๓๕๕",
"pname2":"ชื่อของ pname2"
},
"cc": {
"docname":"คำสั่งเรียกพยานบุคคล",
"blackyear": "๙๐/๒๕๖๙",
"redyear": "xxx",
"curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ",
"commto" : "นายสมชาย จรดปลายเท้า",
"addrno" : "3/77 xxx yyy ทุ่งสองห้อง,หลักสี่, จังหวัดกรุงเทพฯ ",
"groupname":"ผู้อำนวยการโรงเรียน",
"accuser": "นายธีรพล ร่มเริง",
"acc1": "ผู้ฟ้องคดี",
"accused": "รร",
"acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
"detail2":"คำสั่งเพิ่มเติมคือ bla bla",
"remarkhead":"หัวของ Remark",
"remark":"This is remark Text bla bla "
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
manifest ของ Word add-in "e-Court Documents Versioning" (พอร์ตจาก D:\word-addin-poc\addin\manifest.xml)
ไฟล์นี้เป็น **template** — URL ทุกตัวเป็น absolute https ตามข้อบังคับของ Office
โดยค่า default คือ dev (https://localhost:3000/docedit/wordaddin) และถูก serve
ผ่าน route `app/wordaddin/manifest.xml/route.ts` ที่ rewrite origin ให้ตรงกับ
โฮสต์ที่ขอไฟล์อัตโนมัติ (dev/UAT/production ใช้ template เดียวกัน — เข้าถึงที่
/docedit/wordaddin/manifest.xml ของ environment นั้น ๆ ได้เลย)
(รายละเอียดวิธีรัน dev + sideload: docs/WORDADDIN-DEV.md)
ผลต่างจากต้นฉบับ:
- taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack
- ตัด DialogAPI (login ช่วงนี้เป็น mock ไม่มี Keycloak dialog) — เหลือ WordApi 1.5 + OpenBrowserWindowApi
- ตัด AppDomain ของ Keycloak (https://localhost:8443)
- Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane)
- คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม
-->
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
<Version>1.0.0.2</Version>
<ProviderName>e-Court Documents Versioning</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="e-Court Documents Versioning"/>
<Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
<IconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-64.png"/>
<SupportUrl DefaultValue="https://localhost:3000/docedit/wordaddin"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Document"/>
</Hosts>
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
WordApi 1.3: API สูงสุดที่ใช้นอกเหนือจาก close() — customXmlParts (1.3), customProperties /
document.save() / contentControls (1.1) — และเป็นระดับสูงสุดที่ Office 2019 perpetual
รองรับ (WordApi 1.4/1.5 = Microsoft 365 เท่านั้น — เดิมตั้ง 1.5 ทำให้เครื่องศาลที่ใช้
Office 2019 โหลด add-in ไม่ขึ้นเลย 2026-10-16); document.close() (FR-18) ตรวจ runtime
แยกที่ canCloseDocument() → isSetSupported("WordApi","1.5") — เครื่องที่ไม่ถึงซ่อนปุ่ม
ปิดเอกสารทุกแบบ (พฤติกรรมเดียวกับ Word on the web)
OpenBrowserWindowApi 1.1: เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
<Requirements>
<Sets DefaultMinVersion="1.1">
<Set Name="WordApi" MinVersion="1.3"/>
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
</Sets>
</Requirements>
<DefaultSettings>
<SourceLocation DefaultValue="https://localhost:3000/docedit/wordaddin"/>
</DefaultSettings>
<Permissions>ReadWriteDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">
<DesktopFormFactor>
<GetStarted>
<Title resid="GetStarted.Title"/>
<Description resid="GetStarted.Description"/>
<LearnMoreUrl resid="GetStarted.LearnMoreUrl"/>
</GetStarted>
<FunctionFile resid="Commands.Url"/>
<ExtensionPoint xsi:type="PrimaryCommandSurface">
<OfficeTab id="TabHome">
<Group id="CommandsGroup">
<Label resid="CommandsGroup.Label"/>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Control xsi:type="Button" id="TaskpaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<TaskpaneId>ButtonId1</TaskpaneId>
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="GetStarted.LearnMoreUrl" DefaultValue="https://go.microsoft.com/fwlink/?LinkId=276812"/>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GetStarted.Title" DefaultValue="Get started with your add-in!"/>
<bt:String id="CommandsGroup.Label" DefaultValue="e-Court Add-in"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="e-Court Documents Versioning"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GetStarted.Description" DefaultValue="Your add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Opens the e-Court Documents Versioning task pane."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>

153
scripts/fetch-office-js.mjs Normal file
View file

@ -0,0 +1,153 @@
// ดาวน์โหลด Office.js runtime "ฉบับล่าสุด" จาก CDN อย่างเป็นทางการ
// (appsforoffice.microsoft.com/lib/1/hosted) มา self-host ที่ public/wordaddin/office-js/
// ให้ taskpane ของ wordaddin โหลดแบบ same-origin ได้ (/docedit/wordaddin/office-js/office.js)
// — ระบบ deploy ใช้ใน intranet จึง self-host (CDN ใช้ไม่ได้ในเครือข่ายศาล) ส่วนตัว
// ดาวน์โหลดเองตอน npm/pnpm install บนเครื่องที่มีอินเทอร์เน็ต เหมือนดึง dependency อื่น ๆ
//
// ทำไมไม่ใช้แพ็กเกจ @microsoft/office-js แบบเดิม: npm หยุดอัปเดตที่ 1.1.110 (~2018)
// ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ที่ FR-18 ต้องใช้ (เรียกแล้ว
// ได้ "context.document.close is not a function") และไม่มี enum Word.CloseBehavior /
// Word.SaveBehavior ทับ ๆ กับ @types/office-js ฉบับใหม่ — จึงต้องเป็นฉบับ CDN ล่าสุดที่ทัน
// API ปัจจุบัน (rolling — ได้ตัวใหม่ทุกครั้งที่ติดตั้ง และตรงกับ @types ที่ compile ด้วย)
//
// ชุดไฟล์ = **เฉพาะของ Word** ตามที่ loader อ้างถึง: ไฟล์ host ราย platform จากตาราง
// เวอร์ชันใน office.js (word: {ios:16.00, mac:16.00, web:16.00, win32:16.01}) + ไฟล์
// legacy 15.x กัน Word รุ่นเก่าในองค์กร + o15apptofilemappingtable/microsoftajax ที่
// loader ดึง + office_strings.js ต่อ locale — ไฟล์หลักหายตัวไหน (CDN เปลี่ยนชื่อ/ลบ) =
// exit 1 ดัง ๆ ทันที ไม่ปล่อยให้ taskpane พังเงียบ ๆ (pattern เดียวกับ copy-swagger-ui.mjs)
// ยกเว้น office_strings.js ของ locale รอง — หายเป็น warning (loader fallback เป็น en-us)
//
// หลังดาวน์โหลดตรวจ API surface ของ word-win32-16.01.js อีกชั้น: ต้องมี "Document.close"
// และ CloseBehavior — วันหนึ่งถ้า CDN ถอด API ที่ add-in ใช้ ให้ติดตั้งพังตั้งแต่ต้น
// ไม่ใช่มาพังตอนผู้ใช้กดปุ่มใน Word
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
const CDN = "https://appsforoffice.microsoft.com/lib/1/hosted";
const DEST = "public/wordaddin/office-js";
/** ไฟล์หลักที่ loader ดึงตอนรันจริง — หายตัวไหน = พังทั้ง taskpane */
const REQUIRED_FILES = [
"office.js",
"o15apptofilemappingtable.js",
"microsoftajax.js",
// host files ราย platform (ตารางเวอร์ชันของ loader) + legacy 15.x
"word-win32-16.01.js",
"word-web-16.00.js",
"word-mac-16.00.js",
"word-ios-16.00.js",
"word-15.js",
"word-15.01.js",
"word-15.02.js",
"wordios-15.js",
];
/** locale ที่หายไม่ได้ (ผู้ใช้หลักของระบบ) — locale อื่นหายเป็น warning */
const REQUIRED_LOCALES = ["en-us", "th-th"];
/** locale ที่ CDN มี office_strings.js จริง (snapshot 2026-10 — Microsoft รวม locale รอง
* ให้ fallback ไป en-us แล้ว เหลือ ~64 ภาษาหลัก) — locale อื่น ๆ ของผู้ใช้จะได้ข้อความ
* อังกฤษ ไม่ใช่ taskpane พัง */
const LOCALES = [
"am-et", "ar-sa", "bg-bg", "bn-in", "ca-es", "cs-cz", "cy-gb", "da-dk", "de-de", "el-gr",
"en-us", "es-es", "es-mx", "et-ee", "eu-es", "fa-ir", "fi-fi", "fil-ph", "fr-ca", "fr-fr",
"gl-es", "gu-in", "he-il", "hi-in", "hr-hr", "hu-hu", "id-id", "is-is", "it-it", "ja-jp",
"kk-kz", "kn-in", "ko-kr", "lo-la", "lt-lt", "lv-lv", "ml-in", "mr-in", "ms-my", "nb-no",
"nl-nl", "nn-no", "pl-pl", "pt-br", "pt-pt", "ro-ro", "ru-ru", "sk-sk", "sl-si", "sr-cyrl-cs",
"sr-cyrl-rs", "sr-latn-cs", "sr-latn-rs", "sv-se", "sw-ke", "ta-in", "te-in", "th-th", "tr-tr",
"uk-ua", "ur-pk", "vi-vn", "zh-cn", "zh-tw",
];
/** ดึงไฟล์จาก CDN แบบ retry — คืน null เมื่อล้มทุกรอบ (เรียกต่อไปตัดสิน fatal/warning เอง) */
async function fetchText(path, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await fetch(`${CDN}/${path}`, { redirect: "follow" });
if (response.ok) {
return { text: await response.text(), lastModified: response.headers.get("last-modified") };
}
if (response.status === 404) return null; // ไม่มีจริง — retry ไม่ช่วย
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === retries) {
console.error(`[fetch-office-js] ดึง ${path} ไม่สำเร็จหลัง retry: ${error?.message ?? error}`);
return null;
}
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
return null;
}
function save(path, text) {
const target = join(DEST, path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, text);
}
// ลบโฟลเดอร์เดิมเป็น best-effort — บน Windows ตอน next dev เปิดค้าง chokidar อาจจับ
// handle โฟลเดอร์ public/ ไว้ (EPERM แม่น ๆ) ลบไม่ได้ก็เขียนทับในที่เดิมได้ (ไฟล์ที่ CDN
// เลิกใช้จะตกค้างเงียบ ๆ ใน dev แต่ fresh install/Docker ได้โฟลเดอร์สะอาดตามปกติ)
try {
rmSync(DEST, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
} catch {
console.warn("[fetch-office-js] ลบโฟลเดอร์เดิมไม่ได้ (process อื่นจับ handle อยู่ เช่น dev server) — เขียนทับในที่เดิม");
}
mkdirSync(DEST, { recursive: true });
// --- ไฟล์หลัก: ทีละตัว (ไฟล์ใหญ่) ตัวไหนหาย = fatal ---
let officeLastModified;
for (const file of REQUIRED_FILES) {
const result = await fetchText(file);
if (!result) {
console.error(
`[fetch-office-js] ไม่ได้ไฟล์หลัก ${file} — CDN เปลี่ยนชื่อ/ลบไฟล์รึเปล่า ` +
`(ปรับ REQUIRED_FILES ใน scripts/fetch-office-js.mjs) หรือเครือข่ายสู่ appsforoffice.microsoft.com ไม่ผ่าน`,
);
process.exit(1);
}
save(file, result.text);
if (file === "office.js") officeLastModified = result.lastModified;
}
// --- locale strings: ยิงกลุ่มละ 8 ไฟล์ (ไฟล์เล็ก) — รองหาย = warning ---
const missingLocales = [];
for (let i = 0; i < LOCALES.length; i += 8) {
const batch = LOCALES.slice(i, i + 8);
await Promise.all(
batch.map(async (locale) => {
const result = await fetchText(`${locale}/office_strings.js`);
if (result) {
save(`${locale}/office_strings.js`, result.text);
} else {
missingLocales.push(locale);
}
}),
);
}
if (missingLocales.length > 0) {
console.warn(`[fetch-office-js] office_strings.js หาย ${missingLocales.length} locale (loader จะ fallback en-us): ${missingLocales.join(", ")}`);
}
const missingRequiredLocales = REQUIRED_LOCALES.filter((locale) => missingLocales.includes(locale));
if (missingRequiredLocales.length > 0) {
console.error(`[fetch-office-js] หาย office_strings.js ของ locale หลัก: ${missingRequiredLocales.join(", ")}`);
process.exit(1);
}
// --- ตรวจ API surface ที่ add-in ใช้จริง (กัน CDN regression หลุดเงียบ) ---
const win32 = readFileSync(join(DEST, "word-win32-16.01.js"), "utf8");
const missingApis = [];
if (!win32.includes('"Document.close"')) missingApis.push('"Document.close" (FR-18 ปิดเอกสาร)');
if (!win32.includes("CloseBehavior")) missingApis.push("CloseBehavior enum");
if (missingApis.length > 0) {
console.error(
`[fetch-office-js] word-win32-16.01.js จาก CDN ไม่มี API: ${missingApis.join(", ")} — ` +
`อย่า deploy ตัวนี้ (ปุ่มปิดเอกสารจะพังใน Word) แจ้งทีมแล้วตรวจ scripts/fetch-office-js.mjs`,
);
process.exit(1);
}
console.log(
`[fetch-office-js] ดาวน์โหลด Office.js runtime ล่าสุดเรียบร้อย ` +
`(office.js ของวันที่ ${officeLastModified ?? "ไม่ทราบ"} — ${REQUIRED_FILES.length} ไฟล์หลัก + ${LOCALES.length - missingLocales.length} locale)`,
);

View file

@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import {
DOCVER_MESSAGE_MAX,
contentDispositionValue,
docverError,
isDocxBytes,
isPdfBytes,
parseDownloadFormat,
parseFieldsJson,
parseVersionIdParam,
parseVersionUploadForm,
sanitizeDisplayFileName,
sanitizeFilePartName,
} from "@/lib/document-versioning";
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
expect(result.ok).toBe(true);
}
describe("parseVersionIdParam", () => {
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
ok(result);
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
});
it("ตกเมื่อไม่ใช่รูป UUID", () => {
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
expect(parseVersionIdParam(raw).ok).toBe(false);
}
});
});
describe("parseDownloadFormat", () => {
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
for (const raw of [null, "", " ", " docx "]) {
const result = parseDownloadFormat(raw);
ok(result);
expect(result.format).toBe("docx");
}
});
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
const pdf = parseDownloadFormat("pdf");
ok(pdf);
expect(pdf.format).toBe("pdf");
for (const raw of ["exe", "PDF", "docxx"]) {
expect(parseDownloadFormat(raw).ok).toBe(false);
}
});
});
describe("isDocxBytes / isPdfBytes", () => {
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
});
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
});
});
describe("parseFieldsJson", () => {
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
ok(result);
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
const empty = parseFieldsJson("{}");
ok(empty);
expect(empty.fields).toBeNull();
});
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
expect(parseFieldsJson(raw).ok).toBe(false);
}
});
});
describe("parseVersionUploadForm", () => {
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
function baseForm(): FormData {
const form = new FormData();
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
return form;
}
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
const form = baseForm();
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
form.append("message", " ");
form.append("fields", '{"blackno":"1"}');
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
const result = parseVersionUploadForm(form);
ok(result);
expect(result.form).toEqual({
clientVersionId: "11111111-1111-4111-8111-111111111111",
message: null,
fileName: "คำสั่งเรียก.docx",
fields: { blackno: "1" },
});
});
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
const form = baseForm();
form.append("oops", "1");
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("oops");
});
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
for (const legacy of ["userId", "userName", "templateId"]) {
const form = baseForm();
form.append(legacy, "x");
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain(legacy);
}
});
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
const form = baseForm();
form.append("message", new File(["x"], "m.txt"));
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("message");
});
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
const edge = baseForm();
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
ok(parseVersionUploadForm(edge));
const over = baseForm();
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
expect(parseVersionUploadForm(over).ok).toBe(false);
});
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
const form = baseForm();
form.append("fields", '{"a":1}');
expect(parseVersionUploadForm(form).ok).toBe(false);
});
});
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
expect(sanitizeDisplayFileName(" ")).toBeNull();
expect(sanitizeDisplayFileName(null)).toBeNull();
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
});
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
const original = "คำสั่งเรียกเอกสาร.docx";
const mangled = Buffer.from(original, "utf8").toString("latin1");
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
expect(sanitizeFilePartName(mangled)).toBe(original);
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
});
});
describe("docverError", () => {
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
});
});
describe("contentDispositionValue", () => {
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
});
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
expect(contentDispositionValue("Report Final", "docx")).toBe(
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
);
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
);
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
);
});
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
'attachment; filename="document.docx"; filename*=UTF-8\'\''
+ encodeURIComponent("คำสั่งเรียก"),
);
});
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
+ encodeURIComponent("รายงาน 2026"),
);
});
});

View file

@ -0,0 +1,316 @@
import { createHmac } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocverApiKey,
requireDocverBearer,
verifyDocverToken,
type DocverTokenClaims,
} from "@/lib/docver-token";
// docver token (PLAN-DOCVER.md §auth) — validation/auth ล้วนของ /api/documents/*:
// ไฟล์นี้ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ
// external-api-auth.test.ts — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ต้องตั้ง
// DATABASE_URL/MINIO_*)
const SECRET = "test-secret-docver";
const CLAIMS: DocverTokenClaims = {
userid: "123",
username: "Sorawit Bholsithi",
fid: "3",
template: "T12",
};
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
expect(result.ok).toBe(true);
}
// --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load
const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY");
const ORIG_API_KEY = process.env.DOCVER_API_KEY;
const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET");
const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET;
afterEach(() => {
if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY;
else delete process.env.DOCVER_API_KEY;
if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET;
else delete process.env.DOCVER_TOKEN_SECRET;
});
function reqWithKey(key?: string): Request {
return new Request("http://localhost/docedit/api/documents/token", {
headers: key === undefined ? {} : { "x-api-key": key },
});
}
function reqWithBearer(authorization: string | null): Request {
return new Request("http://localhost/docedit/api/documents/doc1/versions", {
headers: authorization === null ? {} : { authorization },
});
}
async function bodyOf(res: Response): Promise<{ error: string }> {
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
return (await res.json()) as { error: string };
}
/** เซ็น payload ใด ๆ ด้วย SECRET ผ่านมือ — ใช้ประกอบเคส claims ผิดรูปที่ issueDocverToken สร้างเองไม่ได้ */
function signPayload(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString(
"base64url",
);
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const signature = createHmac("sha256", SECRET)
.update(`${header}.${body}`)
.digest("base64url");
return `${header}.${body}.${signature}`;
}
describe("issueDocverToken / verifyDocverToken", () => {
it("issue → verify ได้ claims ครบทุกตัว (userid/username/fid/template)", () => {
const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET);
ok(verified);
expect(verified.claims).toEqual(CLAIMS);
});
it("template เป็นทางเลือก (null ได้) แต่ fid บังคับ — token ไม่มี fid = ไม่ผ่าน", () => {
const verified = verifyDocverToken(
issueDocverToken({ userid: "u1", username: "ชื่อ", fid: "7", template: null }, SECRET, 60),
SECRET,
);
ok(verified);
expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: "7", template: null });
const noFid = signPayload({
userid: "u1",
username: "n",
template: "T1",
iat: 0,
exp: Math.floor(Date.now() / 1000) + 60,
});
expect(verifyDocverToken(noFid, SECRET).ok).toBe(false);
});
it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => {
const token = issueDocverToken(CLAIMS, SECRET, 60);
const [header, , signature] = token.split(".");
const tamperedPayload = Buffer.from(
JSON.stringify({ ...CLAIMS, userid: "999" }),
"utf8",
).toString("base64url");
expect(verifyDocverToken(`${header}.${tamperedPayload}.${signature}`, SECRET).ok).toBe(false);
expect(verifyDocverToken(token, "wrong-secret").ok).toBe(false);
});
it("header alg อื่น (none / HS512) = ไม่ผ่าน แม้จะเซ็นด้วย secret ถูก (กัน alg-confusion)", () => {
const body = Buffer.from(
JSON.stringify({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }),
"utf8",
).toString("base64url");
for (const alg of ["none", "HS512"]) {
const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" }), "utf8").toString("base64url");
const signature = createHmac("sha256", SECRET)
.update(`${header}.${body}`)
.digest("base64url");
expect(verifyDocverToken(`${header}.${body}.${signature}`, SECRET).ok).toBe(false);
}
});
it("token หมดอายุ (ttl ติดลบ) หรือไม่มี exp = ไม่ผ่าน", () => {
expect(verifyDocverToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET).ok).toBe(false);
const noExp = signPayload({ ...CLAIMS, iat: Math.floor(Date.now() / 1000) });
expect(verifyDocverToken(noExp, SECRET).ok).toBe(false);
});
it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => {
const now = Math.floor(Date.now() / 1000) + 60;
for (const bad of [
{ username: "n", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี userid
{ userid: "u", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี username
{ userid: "", username: "n", fid: "3", template: null, iat: 0, exp: now }, // userid ว่าง
{ userid: "u", username: "n", fid: 3, template: null, iat: 0, exp: now }, // fid เป็น number
{ userid: "u", username: "n", fid: "", template: null, iat: 0, exp: now }, // fid ว่าง
{ userid: "u", username: "n", template: null, iat: 0, exp: now }, // ไม่มี fid (บังคับแล้ว)
{ userid: "u", username: "n", fid: null, template: null, iat: 0, exp: now }, // fid null
]) {
expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false);
}
});
it("รูป token ผิด (ไม่ใช่ 3 segment / segment เป็น base64 ปกติไม่ใช่ base64url) = ไม่ผ่าน", () => {
expect(verifyDocverToken("not-a-jwt", SECRET).ok).toBe(false);
expect(verifyDocverToken("a.b.c.d", SECRET).ok).toBe(false);
// base64 ปกติมี + / ซึ่ง base64url decode แล้วได้ byte ผิด → header/payload พัง
expect(verifyDocverToken("???.???.???", SECRET).ok).toBe(false);
});
});
describe("requireDocverApiKey", () => {
it("env ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => {
for (const value of [undefined, "", " "]) {
if (value === undefined) delete process.env.DOCVER_API_KEY;
else process.env.DOCVER_API_KEY = value;
const res = requireDocverApiKey(reqWithKey("test-key"));
expect(res?.status).toBe(401);
expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" });
}
});
it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => {
process.env.DOCVER_API_KEY = "test-key";
const missing = requireDocverApiKey(reqWithKey());
const wrong = requireDocverApiKey(reqWithKey("not-the-key"));
expect(missing?.status).toBe(401);
expect(wrong?.status).toBe(401);
expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!));
});
it("key ตรง = ผ่าน (คืน null) · env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้", () => {
process.env.DOCVER_API_KEY = "test-key";
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
process.env.DOCVER_API_KEY = " test-key ";
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
});
});
describe("requireDocverBearer", () => {
it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => {
const deniedBodies = new Set<string>();
const cases: { label: string; secret: string | undefined; authorization: string | null }[] = [
{ label: "ไม่มี Authorization", secret: SECRET, authorization: null },
{ label: "scheme อื่น (Basic)", secret: SECRET, authorization: "Basic dXNlcjpwYXNz" },
{ label: "Bearer ว่างเปล่า", secret: SECRET, authorization: "Bearer " },
{ label: "token เน่า", secret: SECRET, authorization: "Bearer garbage.token.here" },
{
label: "token หมดอายุ",
secret: SECRET,
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, -10)}`,
},
{
label: "เซ็นด้วย secret ผิด",
secret: SECRET,
authorization: `Bearer ${issueDocverToken(CLAIMS, "other-secret", 60)}`,
},
{ label: "secret ไม่ตั้ง (fail closed)", secret: undefined, authorization: null },
{
label: "secret ไม่ตั้งแม้จะมี token ถูก",
secret: undefined,
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`,
},
];
for (const testCase of cases) {
if (testCase.secret === undefined) delete process.env.DOCVER_TOKEN_SECRET;
else process.env.DOCVER_TOKEN_SECRET = testCase.secret;
const auth = requireDocverBearer(reqWithBearer(testCase.authorization));
expect(auth.ok, testCase.label).toBe(false);
if (!auth.ok) {
expect(auth.response.status, testCase.label).toBe(401);
deniedBodies.add(JSON.stringify(await bodyOf(auth.response)));
}
}
expect(deniedBodies.size).toBe(1); // ข้อความ 401 เดียวกันหมด — ไม่ใช้ endpoint เป็น oracle
});
it("Bearer ที่ verify ผ่าน = ได้ claims · คำว่า bearer พิมพ์เล็กก็รับ (case-insensitive)", () => {
process.env.DOCVER_TOKEN_SECRET = SECRET;
const token = issueDocverToken(CLAIMS, SECRET, 60);
const auth = requireDocverBearer(reqWithBearer(`Bearer ${token}`));
ok(auth);
expect(auth.claims).toEqual(CLAIMS);
const lower = requireDocverBearer(reqWithBearer(`bearer ${token}`));
ok(lower);
expect(lower.claims).toEqual(CLAIMS);
});
it("env มี whitespace รอบ secret ก็ trim แล้ว verify ได้", () => {
process.env.DOCVER_TOKEN_SECRET = ` ${SECRET} `;
const auth = requireDocverBearer(
reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`),
);
ok(auth);
expect(auth.claims).toEqual(CLAIMS);
});
});
describe("parseTokenRequestBody", () => {
it("ครบทุก field = ได้ claims (trim ค่า) · template ไม่ส่ง = null (fid บังคับ)", () => {
const full = parseTokenRequestBody({
userid: " 123 ",
username: " Sorawit Bholsithi ",
fid: " 3 ",
template: " T12 ",
});
ok(full);
expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12" });
const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit", fid: "3" });
ok(minimal);
expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: "3", template: null });
});
it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => {
const result = parseTokenRequestBody({ userid: "123", username: "n", fid: "3", template: " " });
ok(result);
expect(result.claims.template).toBeNull();
});
it("ไม่ใช่ object (array / string / null) = 400", () => {
for (const raw of [[], "text", null]) {
const result = parseTokenRequestBody(raw);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe("body ต้องเป็น JSON object");
}
});
it("key อื่นนอก allowlist = 400 ไม่รู้จัก field (กัน typo เงียบ ๆ)", () => {
const result = parseTokenRequestBody({ userid: "1", username: "n", oops: 1 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("oops");
});
it("ขาด userid หรือ username / ส่งมาไม่ใช่ string = 400 ข้อความเฉพาะ field", () => {
for (const raw of [
{ username: "n" },
{ userid: " ", username: "n" },
{ userid: 123, username: "n" },
{ userid: "1" },
{ userid: "1", username: "" },
]) {
const result = parseTokenRequestBody(raw);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/ต้องระบุ (userid|username)/);
}
}
});
it("fid ขาด/null/ว่าง/ไม่ใช่ string (number) หรือ template ไม่ใช่ string = 400", () => {
const missing = parseTokenRequestBody({ userid: "1", username: "n" });
expect(missing.ok).toBe(false);
if (!missing.ok) expect(missing.error).toContain("fid");
for (const raw of [
{ userid: "1", username: "n", fid: null },
{ userid: "1", username: "n", fid: 3 },
{ userid: "1", username: "n", fid: "" },
{ userid: "1", username: "n", fid: " " },
{ userid: "1", username: "n", template: 12 },
]) {
expect(parseTokenRequestBody(raw).ok).toBe(false);
}
});
it("ความยาวเกิน cap: userid > 100 / username > 200 / fid > 200 / template > 100 = 400", () => {
expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false);
expect(
parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok,
).toBe(false);
expect(
parseTokenRequestBody({ userid: "1", username: "n", fid: "a".repeat(201) }).ok,
).toBe(false);
expect(
parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok,
).toBe(false);
});
});

View file

@ -4,11 +4,13 @@
// basePath ให้อัตโนมัติ (path ที่เขียนถึง route จึงต้องมี /note นำหน้าเอง เช่น "/note/login")
// ยกเว้น API route อยู่ใต้ app/api/note/** (URL /docedit/api/note/…) — path ที่ขึ้นต้น
// "/api/" เติมแค่ basePath (caller เขียน "/api/note/…" เอง) ไม่มี /note ซ้อน
// และ static ของ sibling wordaddin ใต้ public/wordaddin/** (URL /docedit/wordaddin/…
// เช่น office.js ที่ self-host) — path ที่ขึ้นต้น "/wordaddin/" เติมแค่ basePath เช่นกัน
// (pdfUrl ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้เท่านั้น)
export function withBasePath(path: string): string {
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith("/api/")) {
if (path.startsWith("/api/") || path.startsWith("/wordaddin/")) {
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
}
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${process.env.NEXT_PUBLIC_APP_PATH ?? ""}${path}`;

41
utils/request-origin.ts Normal file
View file

@ -0,0 +1,41 @@
// origin เต็มของ request ที่เข้ามา — ใช้ประกอบ URL เต็มที่จะส่งกลับออกไปข้างนอก
// (deep link ของ N10 / manifest.xml ของ wordaddin) เพราะ request ที่เห็นฝั่ง app
// มักผ่าน reverse proxy หลัง (IIS ของทางศาล) จึงต้องดู header ของ proxy ก่อน
//
// ถอดมาจาก requestOrigin เดิมใน app/api/note/external/documents/route.ts
// (2026-10-16) ให้ route manifest ของ wordaddin ใช้ร่วม
/** origin เต็มของ request นี้: เชื่อ header ของ reverse proxy ก่อน
* (x-forwarded-proto / x-forwarded-host — proxy หลายชั้นส่งเป็น comma list
* ใช้ค่าแรก) แล้วค่อย fallback จาก host/protocol ของ request ตรง ๆ
*
* แล้วบังคับ https เมื่อโฮสต์ปลายทางไม่ใช่ localhost แต่ได้ proto เป็น http:
* hop ภายในหลัง proxy เป็น http ปกติ (อาจไม่มี x-forwarded-proto ให้ — Next
* server ก็ฉีด `x-forwarded-proto: http` ให้เองด้วย เชื่อ "ใครเป็นคนตั้ง" ไม่ได้)
* ส่วนผู้บริโภค URL พวกนี้ (Word สำหรับ manifest — รับ https เท่านั้น ยกเว้น
* localhost, browser สำหรับ deep link ของ N10) เข้าถึงโฮสต์จริงผ่าน https
* เสมอ — ค่า http://<โฮสต์ที่ไม่ใช่ localhost> ใช้ไม่ได้จริง จึงชี้ขาดจากตัว
* โฮสต์ปลายทางแทนการเดาว่า header มาจาก proxy หรือถูกฉีด */
export function requestOrigin(req: Request): string {
const url = new URL(req.url);
const host =
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
req.headers.get("host") ??
url.host;
const forwardedProto =
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
url.protocol.replace(":", "");
// host อาจติด port มาด้วย (localhost:3000) — ถอดออกก่อนเทียบ (URL จัดการ
// IPv6 ในวงเล็บ [::1] ให้ด้วย เขียน split(":") เองจะพัง)
const hostname = (() => {
try {
return new URL(`http://${host}`).hostname;
} catch {
return host;
}
})();
const isLocal =
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
const proto = !isLocal && forwardedProto === "http" ? "https" : forwardedProto;
return `${proto}://${host}`;
}

13
wordaddin/config.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
*
* ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl —
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
*/
export const config = {
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01",
};

View file

@ -0,0 +1,58 @@
/**
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\models\versionRecord.ts
*/
/** response ของ POST /api/documents/{documentId}/versions */
export interface UploadVersionResult {
hasPdf: boolean;
versionId: string;
versionNumber: number;
}
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
export interface FieldSnapshotEntry {
value: string;
missing: boolean;
}
/**
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
*/
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
/** item ของ GET /api/documents/{documentId}/versions */
export interface VersionRecord {
hasPdf?: boolean;
versionId: string;
versionNumber: number;
/** ISO-8601 */
createdAt: string;
createdByUserId: string;
/** SHA256 hex */
contentHash: string;
fields: FieldsSnapshot | null;
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
message: string | null;
}
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
export function readSnapshotField(
snapshot: FieldsSnapshot | null,
key: string
): FieldSnapshotEntry | null {
if (!snapshot) {
return null;
}
const raw = snapshot[key];
if (raw === undefined) {
return null;
}
if (typeof raw === "string") {
return { value: raw, missing: false };
}
return { value: raw.value, missing: raw.missing };
}

View file

@ -0,0 +1,122 @@
import { config } from "../config";
import * as authService from "./authService";
/**
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
* ถูก TaskpaneApp ใช้อยู่แล้ว)
*/
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
export class AuthRequiredError extends Error {
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
super(message);
this.name = "AuthRequiredError";
}
}
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export interface ApiRequestOptions {
method?: string;
headers?: Record<string, string>;
body?: BodyInit | null;
}
function toUrl(path: string): string {
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
}
async function doFetch(
path: string,
options: ApiRequestOptions,
accessToken: string
): Promise<Response> {
const headers = new Headers(options.headers ?? {});
headers.set("Authorization", `Bearer ${accessToken}`);
return fetch(toUrl(path), {
method: options.method ?? "GET",
headers,
body: options.body,
});
}
/**
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
*/
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
const accessToken = await authService.getAccessToken();
if (!accessToken) {
throw new AuthRequiredError();
}
let response = await doFetch(path, options, accessToken);
if (response.status === 401) {
const refreshedToken = await authService.refreshAccessToken();
if (!refreshedToken) {
throw new AuthRequiredError();
}
response = await doFetch(path, options, refreshedToken);
if (response.status === 401) {
throw new AuthRequiredError();
}
}
return response;
}
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = (await response.text()).trim();
if (!text) {
return fallback;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
const detail = parsed.detail ?? parsed.message ?? parsed.title;
if (typeof detail === "string" && detail) {
return detail;
}
} catch {
// plain text — ใช้ตรงๆ
}
return text;
} catch {
return fallback;
}
}
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
const response = await apiFetch(path);
if (!response.ok) {
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
}
return (await response.json()) as T;
}
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
export async function apiSendExpectJson<T>(
path: string,
options: ApiRequestOptions,
fallbackError: string
): Promise<T> {
const response = await apiFetch(path, options);
if (!response.ok) {
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
}
return (await response.json()) as T;
}

View file

@ -0,0 +1,97 @@
/**
* MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต)
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API,
* token เก็บใน memory เท่านั้น, auto-refresh)
*
* วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว
* (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ
*/
import { config } from "../config";
/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */
export type LoginResult = { ok: true } | { ok: false; error: string };
/** state เดียวของ mock — null = signed out */
let username: string | null = null;
/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */
let docToken: string | null = null;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */
export async function login(): Promise<LoginResult> {
await delay(300);
username = config.mockUsername;
return { ok: true };
}
/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */
export function signInForDemo(): void {
username = config.mockUsername;
}
/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */
function decodeJwtField(token: string, field: string): string | undefined {
const payload = token.split(".")[1];
if (!payload) return undefined;
try {
// atob คืน binary string (1 char = 1 byte) — ต้องถอด UTF-8 ด้วย TextDecoder ก่อน JSON.parse
// ไม่งั้น uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) จะกลายเป็น mojibake
const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
const value = typeof json === "object" && json !== null ? (json as Record<string, unknown>)[field] : undefined;
return typeof value === "string" && value !== "" ? value : undefined;
} catch {
return undefined;
}
}
/**
* e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties
* ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername
* คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว)
*/
export function signInFromDocument(input: { token?: string; owner?: string }): boolean {
const token = input.token?.trim() || undefined;
const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined);
if (!token && !owner) {
return false;
}
username = owner ?? config.mockUsername;
docToken = token ?? null;
return true;
}
export async function getAccessToken(): Promise<string | null> {
if (docToken) return docToken;
return username ? `mock-token-${username}` : null;
}
/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */
export async function refreshAccessToken(): Promise<string | null> {
return getAccessToken();
}
export function getUsername(): string | undefined {
return username ?? undefined;
}
export function isTokenValid(): boolean {
return username !== null;
}
export function hasRefreshToken(): boolean {
return username !== null;
}
export function startAutoRefresh(): void {
// mock: ไม่มีอะไรต้อง refresh
}
export function signOut(): void {
username = null;
docToken = null;
}

View file

@ -0,0 +1,208 @@
/* global console */
/**
* FR-17 — Auto Save: บันทึก local (`document.save`) เท่านั้น ห้ามสร้าง version อัตโนมัติ
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
*
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
*/
import { isDocumentDirty, saveDocument } from "./documentService";
export interface AutoSaveConfig {
enabled: boolean;
debounceMs: number;
periodicSaveEnabled: boolean;
periodicIntervalMs: number;
}
/** TS constants ตาม spec §8 — แก้ที่นี่ที่เดียว (ไม่มี runtime config) */
export const autoSaveConfig: AutoSaveConfig = {
enabled: true,
debounceMs: 3_000,
periodicSaveEnabled: true,
periodicIntervalMs: 30_000,
};
class WordAutoSaveService {
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
private periodicTimer: ReturnType<typeof setInterval> | undefined;
private saving = false;
private pendingSave = false;
/** depth ของ suspend() — จะรัน timer ใหม่เฉพาะเมื่อ resume ครบทุกชั้น */
private suspendDepth = 0;
private idleWaiters: Array<() => void> = [];
private started = false;
private uncommitted = false;
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
onError: ((error: unknown) => void) | undefined;
/** save สำเร็จหนึ่งรอบ — App ใช้เคลียร์ banner error เดิม */
onSaved: (() => void) | undefined;
/** state เปลี่ยน (isSaving / hasUncommittedSinceVersion) — App sync React state */
onStateChange: (() => void) | undefined;
get isSaving(): boolean {
return this.saving;
}
/** FR-3 — เคยเห็น dirty หลัง version ล่าสุด (เคลียร์เมื่อสร้าง version สำเร็จ) */
get hasUncommittedSinceVersion(): boolean {
return this.uncommitted;
}
/** FR-17 §16 — เริ่มตอน Office.onReady ได้เลย ไม่ต้องรอ sign-in */
start(): void {
if (!autoSaveConfig.enabled) {
return;
}
this.started = true;
this.startTimers();
}
stop(): void {
this.started = false;
this.stopTimers();
}
/** flow ที่จัดการ save เอง (FR-2/FR-18) — หยุด timer + รอ save ค้างจบ แล้วค่อยทำงานต่อ */
async suspend(): Promise<void> {
this.suspendDepth++;
this.stopTimers();
await this.waitIdle();
}
/** คู่ของ suspend() — ซ้อนกี่ชั้นต้อง resume ครบก่อน timer ถึงจะกลับมา */
resume(): void {
if (this.suspendDepth > 0) {
this.suspendDepth--;
}
if (this.suspendDepth === 0) {
this.startTimers();
}
}
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
notifyDocumentChanged(): void {
if (!autoSaveConfig.enabled) {
return;
}
this.clearDebounce();
this.debounceTimer = setTimeout(() => {
this.debounceTimer = undefined;
this.save().catch((error) => this.reportError(error));
}, autoSaveConfig.debounceMs);
}
/** บันทึกเดี๋ยวนี้ (ยกเลิก debounce ค้าง) — ถ้ามี save กำลังทำงานจะ mark pending แทน (coalesce) */
async save(): Promise<void> {
this.clearDebounce();
if (this.saving) {
this.pendingSave = true;
return;
}
this.saving = true;
this.notifyState();
try {
await saveDocument();
if (this.pendingSave) {
this.pendingSave = false;
await saveDocument();
}
this.onSaved?.();
} finally {
this.pendingSave = false;
this.saving = false;
this.notifyState();
this.resolveIdle();
}
}
/** บันทึกให้เสร็จก่อนกลับ — ใช้ก่อน close/upload (spec auto-save ข้อ 12) เรียกหลัง suspend() เสมอ */
async forceSave(): Promise<void> {
this.clearDebounce();
await this.waitIdle();
await this.save();
await this.waitIdle();
}
/** FR-3 — version ใหม่ถูกสร้างสำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" */
clearUncommittedSinceVersion(): void {
this.uncommitted = false;
this.notifyState();
}
private startTimers(): void {
if (!this.started || !autoSaveConfig.enabled) {
return;
}
if (autoSaveConfig.periodicSaveEnabled && !this.periodicTimer) {
this.periodicTimer = setInterval(() => {
void this.periodicTick();
}, autoSaveConfig.periodicIntervalMs);
}
}
private stopTimers(): void {
this.clearDebounce();
if (this.periodicTimer) {
clearInterval(this.periodicTimer);
this.periodicTimer = undefined;
}
}
private clearDebounce(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
}
}
private async periodicTick(): Promise<void> {
if (this.saving || this.suspendDepth > 0) {
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
}
try {
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
if (!(await isDocumentDirty())) {
return;
}
this.uncommitted = true;
this.notifyState();
await this.save();
} catch (error) {
this.reportError(error);
}
}
private waitIdle(): Promise<void> {
return this.saving
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))
: Promise.resolve();
}
private resolveIdle(): void {
const waiters = this.idleWaiters;
this.idleWaiters = [];
for (const waiter of waiters) {
waiter();
}
}
private notifyState(): void {
this.onStateChange?.();
}
private reportError(error: unknown): void {
console.warn("auto-save failed:", error);
this.onError?.(error);
}
}
export const autoSaveService = new WordAutoSaveService();

View file

@ -0,0 +1,86 @@
/* global Word, Office */
/**
* FR-17 / FR-18 — จุดเดียวที่คุยกับ document-level API ของ Word
* (native save / อ่าน dirty / ปิดเอกสาร) แยกจาก versionFlow เพื่อให้
* autoSaveService กับปุ่ม [บันทึก] ใช้ร่วมได้โดยไม่ต้องพ่วง flow versioning
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\documentService.ts
*/
export enum DocumentCloseMode {
/** บันทึกการแก้ไขก่อนปิด */
Save = "Save",
/** ปิดโดยไม่บันทึก — ไว้ใช้ระดับ service เท่านั้น (UI ปัจจุบันไม่มีปุ่มไหนใช้) */
SkipSave = "SkipSave",
}
/** native save ตาม FR-2 ขั้นตอนที่ 4 — ใช้ร่วมทั้ง versionFlow และ autoSaveService
* เรียกแบบไม่ส่ง saveBehavior: ปลอดภัยทุกรุ่น runtime — รุ่นเก่า (npm 1.1.110) ไม่มี
* enum Word.SaveBehavior และรับ save() แบบไม่มี argument, รุ่นใหม่ param เป็น optional
* ที่ default เดียวกัน (behavior = save เงียบ ๆ) */
export async function saveDocument(): Promise<void> {
await Word.run(async (context) => {
context.document.save();
await context.sync();
});
}
/** FR-3 — มีการแก้ไขที่ยังไม่ถูกบันทึกลงไฟล์ local หรือไม่ */
export async function isDocumentDirty(): Promise<boolean> {
return Word.run(async (context) => {
context.document.load("saved");
await context.sync();
return !context.document.saved;
});
}
/** save แล้วเช็คว่า Word เห็นไฟล์สะอาดจริง — บางสถานะ (เช่น หลังเขียน custom properties /
* custom XML part ของ flow versioning) Word ยังรายงาน saved=false แม้ save ผ่านแล้ว
* จึง save ซ้ำแบบมีขอบเขต (รวมสูงสุด 3 รอบ) — ไม่งั้น periodic tick ของ auto-save
* เห็น dirty ตอนไม่มีใครแก้อะไร แล้ว banner FR-3 ("มีการแก้ไขที่ยังไม่เป็น version")
* โผล่ผิด ๆ หลังจบ flow */
export async function saveDocumentVerified(): Promise<void> {
await saveDocument();
for (let attempt = 0; attempt < 2; attempt += 1) {
if (!(await isDocumentDirty())) return;
await saveDocument();
}
}
/**
* FR-18 — `document.close()` ต้องการ WordApi 1.5 **และใช้ไม่ได้บน Word on the web**
* (requirement-set table ระบุรองรับ แต่หน้าเอกสารของ method เขียนไว้ว่า "isn't
* supported in Word on the web") จึงต้องเช็ค platform คู่กันเสมอ —
* UI ใช้ซ่อนปุ่ม close ทุกแบบ, service ใช้ guard ก่อนเรียกจริง
* (เรียกนอก Word — เช่น SSR หรือ browser ธรรมดา — throw ถูก catch คืน false เสมอ)
*/
export function canCloseDocument(): boolean {
try {
return (
Office.context.requirements.isSetSupported("WordApi", "1.5") &&
Office.context.platform !== Office.PlatformType.OfficeOnline
);
} catch {
return false;
}
}
/** FR-18 — ปิดเอกสารปัจจุบัน (task pane จะปิดตามเอกสาร) */
export async function closeDocument(mode: DocumentCloseMode = DocumentCloseMode.Save): Promise<void> {
if (!canCloseDocument()) {
throw new Error(
"เวอร์ชัน Word นี้ไม่รองรับการปิดเอกสารจาก add-in (รองรับเฉพาะ Word Desktop ที่ใช้ WordApi 1.5 ได้)"
);
}
await Word.run(async (context) => {
// ส่งค่า underlying ของ enum เป็น string ตรง ๆ ไม่อ่าน Word.CloseBehavior.* —
// runtime self-host อาจเป็นรุ่นเก่ากว่า @types (กรณี npm 1.1.110 เดิม: ไม่มี enum นี้เลย
// อ่านแล้ว TypeError "reading 'save'"; ปัจจุบัน fetch ฉบับ CDN ล่าสุดมี enum แล้วแต่ค่า
// string ทำงานเหมือนกันทุกรุ่น — cast กัน @types ที่พิมพ์ parameter เป็น enum object)
context.document.close(
(mode === DocumentCloseMode.Save ? "Save" : "SkipSave") as Word.CloseBehavior
);
// ห้ามเรียก context.sync() หลัง close() — เอกสารปิดกลาง batch ทำให้ sync ถูก abort
// (ตัวอย่างทางการของ Word เรียก close เฉยๆ แล้วพึ่ง implicit sync ตอน run() จบ)
});
}

View file

@ -0,0 +1,28 @@
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string" && error) {
return error;
}
return fallback;
}
/** เหมือน getErrorMessage แต่ต่อท้าย code/errorLocation จาก debugInfo ของ OfficeExtension.Error —
* สำหรับ error จาก Word.run ที่ต้องการสาเหตุละเอียด (เช่น ราย tag ที่เขียนค่าไม่สำเร็จ) */
export function getDetailedErrorMessage(
error: unknown,
fallback = "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
): string {
if (error instanceof Error && error.message) {
const debugInfo = (error as { debugInfo?: { code?: string; errorLocation?: string } }).debugInfo;
const code = debugInfo?.code ? ` [${debugInfo.code}]` : "";
const location = debugInfo?.errorLocation ? ` (${debugInfo.errorLocation})` : "";
return `${error.message}${code}${location}`;
}
if (typeof error === "string" && error) {
return error;
}
return fallback;
}

View file

@ -0,0 +1,128 @@
/* global Office, console */
/**
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\fileService.ts
*/
export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
export const PDF_CONTENT_TYPE = "application/pdf";
const SLICE_SIZE = 1024 * 1024;
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
return new Error(result.error?.message || fallback);
}
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
return new Promise((resolve, reject) => {
try {
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
});
} catch (error) {
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
}
});
}
/**
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบุแค่ "byte array"):
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
*/
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView;
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
}
if (typeof data === "string") {
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
return Uint8Array.from(data);
}
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
}
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
return new Promise((resolve, reject) => {
try {
file.getSliceAsync(sliceIndex, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
try {
resolve(sliceDataToBytes(result.value.data));
} catch (error) {
reject(
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
);
}
} else {
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
}
});
} catch (error) {
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
}
});
}
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
function closeFileQuietly(file: Office.File): Promise<void> {
return new Promise((resolve) => {
try {
file.closeAsync(() => resolve());
} catch (error) {
console.warn("closeAsync failed:", error);
resolve();
}
});
}
/**
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
*/
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
const file = await getFileAsync(fileType);
try {
const chunks: BlobPart[] = [];
for (let index = 0; index < file.sliceCount; index++) {
chunks.push(await getSliceAsync(file, index));
}
return new Blob(chunks, { type: contentType });
} finally {
await closeFileQuietly(file);
}
}
export async function getCurrentDocumentFile(): Promise<Blob> {
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
}
export async function getCurrentDocumentPdf(): Promise<Blob> {
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
}
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
}

View file

@ -0,0 +1,256 @@
/* global Word, console */
/**
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
* - Custom Document Properties = primary flag ตรวจจับ tracked document
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
*
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
*/
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */
export const ECOURT_TOKEN_PROPERTY = "token";
export const ECOURT_OWNER_PROPERTY = "owner";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties {
savedByAddIn?: string;
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string;
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */
ecourtToken?: string;
ecourtOwner?: string;
}
/** item ใน Custom XML Part `<VersionHistory>` */
export interface XmlVersionEntry {
id: string;
number: number;
timestamp: string;
user: string;
}
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
* + token/owner ที่ e-Court stamp มากับไฟล์) */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => {
const properties = context.document.properties.customProperties;
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
const ecourtToken = properties.getItemOrNullObject(ECOURT_TOKEN_PROPERTY);
const ecourtOwner = properties.getItemOrNullObject(ECOURT_OWNER_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value");
documentTrackingId.load("value");
lastVersionId.load("value");
lastVersionTimestamp.load("value");
templateId.load("value");
ecourtToken.load("value");
ecourtOwner.load("value");
await context.sync();
const read = (property: Word.CustomProperty): string | undefined =>
property.isNullObject || property.value === undefined || property.value === null
? undefined
: String(property.value);
return {
savedByAddIn: read(savedByAddIn),
documentTrackingId: read(documentTrackingId),
lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp),
templateId: read(templateId),
ecourtToken: read(ecourtToken),
ecourtOwner: read(ecourtOwner),
};
});
}
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
* หมายเหตุ: TaskpaneApp.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
export async function isTrackedDocument(): Promise<boolean> {
const properties = await readTrackedProperties();
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
}
export interface StampPropertiesInput {
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
}
/**
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
*/
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
await Word.run(async (context) => {
const properties = context.document.properties.customProperties;
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
if (input.documentTrackingId !== undefined) {
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
}
if (input.lastVersionId !== undefined) {
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
}
if (input.lastVersionTimestamp !== undefined) {
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
}
await context.sync();
});
}
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
export function generateGuid(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// ---------------------------------------------------------------------------
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
// ---------------------------------------------------------------------------
function escapeXmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
const elements = entries
.map(
(entry) =>
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
entry.timestamp
)}" user="${escapeXmlAttribute(entry.user)}"/>`
)
.join("\n");
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
}
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
return nodes
.map((node) => ({
id: node.getAttribute("id") ?? "",
number: Number(node.getAttribute("number") ?? Number.NaN),
timestamp: node.getAttribute("timestamp") ?? "",
user: node.getAttribute("user") ?? "",
}))
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
}
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
try {
return await Word.run(async (context) => {
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
const xmlResults = scoped.items.map((part) => part.getXml());
await context.sync();
const entries: XmlVersionEntry[] = [];
for (const result of xmlResults) {
entries.push(...parseVersionHistoryXml(result.value));
}
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
return entries;
});
} catch (error) {
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
return [];
}
}
/**
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
*/
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
try {
await Word.run(async (context) => {
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
for (const part of scoped.items) {
part.delete();
}
if (scoped.items.length > 0) {
await context.sync();
}
// 2) add ของใหม่
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
await context.sync();
});
return true;
} catch (error) {
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
return false;
}
}
// ---------------------------------------------------------------------------
// Content controls → fields (FR-11)
// ---------------------------------------------------------------------------
/**
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
*/
export async function readContentControlFields(): Promise<Record<string, string>> {
return Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("tag, text");
await context.sync();
const fields: Record<string, string> = {};
for (const control of controls.items) {
const tag = (control.tag ?? "").trim();
if (tag === "") {
continue;
}
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
fields[tag] = control.text;
}
}
return fields;
});
}

View file

@ -0,0 +1,240 @@
/* global Word, Office */
/**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls
* ฝั่ง Office.js ล้วน ไม่ยิง backend: สแกนรายช่อง / กระโดดไปเลือกช่อง / เขียนค่าทดสอบกลับ
* เข้าเอกสาร / แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้าที่ cursor
*
* API ที่ใช้เป็น WordApi 1.1 เกือบทั้งหมด (ยืนยันจาก @types/office-js 1.0.612) ยกเว้น
* getByIdOrNullObject (1.3) — ไม่เกินความต้องการต่ำสุดของ manifest (1.3) จึงใช้ได้ทุก platform
* รวม Office 2019 และ Word on the web
*
* ⚠️ collection `contentControls` ธรรมดามองเห็น**เฉพาะ rich text CC** (docstring ของ
* Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น
* plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ)
* ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3
* ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน)
*
* กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน
* browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume
* auto-save คู่กันเสมอ และเรียก notifyDocumentChanged() หลังสำเร็จให้ debounce และ flag FR-3
* ทำงานถูกต้อง (pattern เดียวกับ versionFlow.ts)
*/
import { autoSaveService } from "./autoSaveService";
import { getDetailedErrorMessage } from "./errors";
/** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js
* เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */
export interface ScannedField {
id: number;
/** tag ดิบไม่ trim — เพื่อให้ UI เตือน "tag ว่าง / มี space นำ-ท้าย" ได้ (ตอนเทียบ/ใช้งานคือ trim เสมอ) */
tag: string;
title: string;
text: string;
/** enum ดิบ เช่น "RichTextInline" — ไว้ debug ใน tooltip */
type: string;
/** heuristics หยาบจาก enum type: ลงท้ายด้วย "Inline" = ในบรรทัด ไม่งั้น = บล็อก */
kind: "inline" | "block";
}
function toScannedField(control: Word.ContentControl): ScannedField {
const type = control.type ?? "";
return {
id: control.id,
tag: control.tag ?? "",
title: control.title ?? "",
text: control.text ?? "",
type,
kind: type.endsWith("Inline") ? "inline" : "block",
};
}
/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น
* (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */
export function supportsAllContentControlTypes(): boolean {
try {
return Office.context.requirements.isSetSupported("WordApi", "1.5");
} catch {
return false;
}
}
/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด)
* ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */
function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection {
return supportsAllContentControlTypes()
? context.document.getContentControls()
: context.document.contentControls;
}
/** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error
* อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */
export async function listContentControls(): Promise<ScannedField[]> {
return Word.run(async (context) => {
const controls = supportedContentControls(context);
controls.load("id, tag, title, text, type");
await context.sync();
return controls.items.map(toScannedField);
});
}
/** เลือก + เลื่อน Word ไปที่ช่องตาม id ของผลสแกน — select() ตาม docstring จะ scroll ให้เอง
* ไม่ใช่การแก้เอกสาร (document.saved ไม่ dirty) จึงไม่ต้อง suspend */
export async function selectContentControl(id: number): Promise<void> {
await Word.run(async (context) => {
const control = supportedContentControls(context).getByIdOrNullObject(id);
control.load("id");
await context.sync();
if (control.isNullObject) {
throw new Error("ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่");
}
control.select();
await context.sync();
});
}
/** แทรกย่อหน้าว่างใหม่หลังตำแหน่ง cursor (เหมือนกด Enter) — ปุ่มเสริมตอนจัดโครงสร้าง template
* (คำขอผู้ใช้ 2026-10-19: ตอนแก้ template ต้องเพิ่มย่อหน้าบ่อย แล้วเปิด formatting marks ตรวจ —
* ส่วนการเปิด/ปิด formatting marks เป็น application setting ที่ Office.js ไม่มี API ให้แตะ) */
export async function insertParagraphAtSelection(): Promise<void> {
await autoSaveService.suspend();
try {
await Word.run(async (context) => {
context.document.getSelection().insertParagraph("", "After");
await context.sync();
});
autoSaveService.notifyDocumentChanged();
} finally {
autoSaveService.resume();
}
}
/** page break แบบ paragraph สมบูรณ์ (block-level) — w:p ครอบ run ที่มี <w:br w:type="page"/>
* ใช้เป็นวิธีสำรองเมื่อ insertBreak("Page") โดน E_FAIL (0x80004005) ในบางบริบท เช่น cursor
* อยู่ในตาราง (เคสจริงบน T3.docx 2026-10-19 — template ของศาลมีตารางเป็นโครงหลัก) · ระดับ fragment
* ต้องเป็น block-level เต็มตัว ไม่ใช่ run เดี่ยว (ลองแล้วยัง E_FAIL อีกรอบ) */
const PAGE_BREAK_PARAGRAPH_OOXML =
'<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:r><w:br w:type="page"/></w:r></w:p>';
/** แทรกตัวแบ่งที่ตำแหน่ง cursor — "line" = ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter) สำหรับ
* จัดที่อยู่/รายการให้ตรงแนว, "page" = ขึ้นหน้าใหม่ (Ctrl+Enter) สำหรับเอกสารหลายหน้า (คำขอผู้ใช้ 2026-10-19) */
export async function insertBreakAtSelection(breakKind: "line" | "page"): Promise<void> {
await autoSaveService.suspend();
try {
if (breakKind === "line") {
await Word.run(async (context) => {
context.document.getSelection().insertBreak("Line", "After");
await context.sync();
});
} else {
// วิธีมาตรฐานก่อน — ถ้า host ปฏิเสธ (E_FAIL ในบางบริบท) สลับไป OOXML block-level
// (sync ที่พัง = คำสั่งถูกปฏิเสธ ไม่มีอะไรถูกเขียนค้าง จึง retry ได้; ถ้า retry ก็พัง
// โยน error ที่รวมทั้งสองสาเหตุ ไม่กลืนต้นเหตุแรกหาย)
let firstError: unknown;
try {
await Word.run(async (context) => {
context.document.getSelection().insertBreak("Page", "After");
await context.sync();
});
autoSaveService.notifyDocumentChanged();
return;
} catch (error) {
firstError = error;
}
try {
await Word.run(async (context) => {
context.document.getSelection().insertOoxml(PAGE_BREAK_PARAGRAPH_OOXML, "End");
await context.sync();
});
} catch (fallbackError) {
throw new Error(
`แทรกตัวแบ่งหน้าไม่สำเร็จทั้งสองวิธี — insertBreak: ${getDetailedErrorMessage(firstError)} · OOXML: ${getDetailedErrorMessage(fallbackError)}`,
);
}
}
autoSaveService.notifyDocumentChanged();
} finally {
autoSaveService.resume();
}
}
export interface ApplyFailure {
tag: string;
/** สาเหตุที่อ่านได้ (รวม code/errorLocation จาก OfficeExtension.Error ถ้ามี) */
reason: string;
}
export interface ApplyTestValuesResult {
/** จำนวนช่อง (CC) ที่ถูกเขียนค่าจริง — tag เดียวซ้ำหลายช่องนับทุกช่อง
* ⚠️ ข้อจำกัด granularity ราย tag: ถ้า tag หนึ่งผูกหลายช่องและ batch sync พังกลางคัน
* (ช่องแรกถูกเขียนแล้วแต่ช่องหลังโดนปฏิเสธ) ทั้ง tag จะถูกนับเป็น failures แม้บางช่องเขียนสำเร็จ —
* เรียก listContentControls หลังเขียนเพื่อเห็นค่าจริงในเอกสาร */
appliedCount: number;
/** tag ที่ส่งค่ามาแต่ไม่มีช่องตรงในเอกสารตอนเขียน (เช่น ถูกลบไปหลังสแกน) */
missingTags: string[];
/** tag ที่ Word ปฏิเสธการเขียน (เช่น เอกสารถูก Restrict Editing หรือช่องถูกล็อค/แก้ไม่ได้) —
* เขียนแบบแยกราย tag เพื่อให้เหลืออื่น ๆ ยังสำเร็จและรู้สาเหตุเป็นรายตัว */
failures: ApplyFailure[];
}
/** เขียนค่าทดสอบกลับเข้าทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่อง แบบตัวแปรเดียวหลายจุด)
* - ค่าที่เว้นว่างจะไม่ถูกแตะ (กันเผลอล้างค่าจริงของช่องนั้น) — ว่างทั้งหมด = ไม่เปิด Word.run
* - อ่าน id+tag สดรอบเดียว ไม่เชื่อ snapshot สแกน แล้วเขียน**แยก Word.run ราย tag** —
* tag ที่ Word ปฏิเสธ (เอกสารถูก Restrict Editing / ช่องแก้ไม่ได้) fail เฉพาะตัว เหลืออื่นยังเขียนได้
* และเหตุผลถูกเก็บลง failures (กรณีเคยเจอจริง: error 0x800A16C6 บนเอกสารที่ make-protected ล็อค) */
export async function applyTestValues(values: Record<string, string>): Promise<ApplyTestValuesResult> {
const entries = Object.entries(values).filter(([, value]) => value.trim() !== "");
if (entries.length === 0) {
return { appliedCount: 0, missingTags: [], failures: [] };
}
await autoSaveService.suspend();
try {
// สำรวจ id ของ CC ราย tag รอบเดียว (เทียบหลัง trim เหมือนฟอร์ม) — id ใช้ได้ทั้ง session นี้
const idsByTag = new Map<string, number[]>();
await Word.run(async (context) => {
const controls = supportedContentControls(context);
controls.load("id, tag");
await context.sync();
for (const control of controls.items) {
const tag = (control.tag ?? "").trim();
if (tag === "" || !(tag in values)) continue;
const ids = idsByTag.get(tag) ?? [];
ids.push(control.id);
idsByTag.set(tag, ids);
}
});
let appliedCount = 0;
const missingTags: string[] = [];
const failures: ApplyFailure[] = [];
for (const [tag, value] of entries) {
const ids = idsByTag.get(tag);
if (!ids || ids.length === 0) {
missingTags.push(tag);
continue;
}
try {
let written = 0;
await Word.run(async (context) => {
const targets = ids.map((id) => supportedContentControls(context).getByIdOrNullObject(id));
targets.forEach((control) => control.load("id"));
await context.sync();
const alive = targets.filter((control) => !control.isNullObject);
alive.forEach((control) => control.insertText(value, "Replace"));
await context.sync();
written = alive.length;
});
appliedCount += written;
} catch (tagError) {
failures.push({ tag, reason: getDetailedErrorMessage(tagError) });
}
}
if (appliedCount > 0) {
autoSaveService.notifyDocumentChanged();
}
return { appliedCount, missingTags, failures };
} finally {
autoSaveService.resume();
}
}

View file

@ -0,0 +1,229 @@
/* global Office, console */
/**
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\versionFlow.ts
*
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
*
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
* (backend คือ source of truth ของ id จริงอยู่ดี)
*/
import * as authService from "./authService";
import { autoSaveService } from "./autoSaveService";
import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./documentService";
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
import {
generateGuid,
readContentControlFields,
readVersionHistoryFromXml,
stampTrackedProperties,
writeVersionHistoryToXml,
XmlVersionEntry,
} from "./metadataService";
import { uploadVersion } from "./versioningService";
export interface StartVersioningResult {
hasPdf: boolean;
documentId: string;
versionId: string;
versionNumber: number;
}
export interface SaveAndVersionResult {
hasPdf: boolean;
versionId: string;
versionNumber: number;
}
/**
* FR-18 — version ถูกบันทึกสำเร็จแล้วแต่ปิดเอกสารไม่สำเร็จ (เอกสารยังเปิดอยู่)
* UI ต้องแยกสถานะนี้ออกจาก "สร้าง version ไม่สำเร็จ" — version ปลอดภัยบน backend แล้ว
*/
export class VersionSavedButCloseFailedError extends Error {
constructor(
public readonly result: SaveAndVersionResult,
cause: unknown
) {
super(
`บันทึก version ${result.versionNumber} เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: ${
cause instanceof Error ? cause.message : String(cause)
} — ปิดเอกสารเองได้เลย`
);
this.name = "VersionSavedButCloseFailedError";
}
}
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
function guessDocumentFileName(): string | undefined {
try {
const url = Office.context.document.url;
if (!url) {
return undefined;
}
const name = url.split(/[\\/]/).pop();
if (!name) {
return undefined;
}
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
} catch {
return undefined;
}
}
/** `context.document.save()` ย้ายไป documentService (ใช้ร่วมกับ FR-17 auto-save) */
async function uploadCurrentFile(
documentId: string,
clientVersionId: string,
fields: Record<string, string>,
message?: string,
onProgress?: (progress: string) => void,
templateId?: string
): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile();
let pdf: Blob | undefined;
onProgress?.("กำลังสร้างสำเนา PDF…");
try {
pdf = await getCurrentDocumentPdf();
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
} catch (error) {
console.warn("PDF capture failed:", error);
pdf = undefined;
}
onProgress?.("กำลังอัปโหลด version…");
return uploadVersion(
documentId,
file,
clientVersionId,
fields,
guessDocumentFileName(),
message,
pdf,
templateId
);
}
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
* (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */
export async function startVersioning(
message?: string,
templateId?: string,
onProgress?: (progress: string) => void
): Promise<StartVersioningResult> {
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
await autoSaveService.suspend();
try {
const documentId = generateGuid();
const clientVersionId = generateGuid();
const timestamp = new Date().toISOString();
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
await stampTrackedProperties({
documentTrackingId: documentId,
lastVersionId: clientVersionId,
lastVersionTimestamp: timestamp,
});
// 2) XML part (secondary — fail ได้เงียบๆ)
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
await writeVersionHistoryToXml([
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
]);
// 3) native save แล้ว capture ไฟล์ + fields
onProgress?.("กำลังบันทึกไฟล์…");
await saveDocumentVerified();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId);
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
autoSaveService.clearUncommittedSinceVersion();
return { documentId, ...result };
} finally {
autoSaveService.resume();
}
}
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว)
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
* (FR-18: closeAfter=true — หลัง upload สำเร็จเท่านั้น ปิดเอกสารด้วย close(Save);
* ปิดไม่สำเร็จโยน VersionSavedButCloseFailedError ไม่ใช่ error ของ version) */
export async function saveAndVersion(
documentId: string,
latestVersionNumber: number,
message?: string,
onProgress?: (progress: string) => void,
closeAfter = false
): Promise<SaveAndVersionResult> {
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
await autoSaveService.suspend();
try {
const clientVersionId = generateGuid();
const timestamp = new Date().toISOString();
const predictedNumber = latestVersionNumber + 1;
const user = authService.getUsername() ?? "unknown";
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
await stampTrackedProperties({
lastVersionId: clientVersionId,
lastVersionTimestamp: timestamp,
});
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
const entries = await readVersionHistoryFromXml();
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
await writeVersionHistoryToXml(entries);
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
onProgress?.("กำลังบันทึกไฟล์…");
await saveDocumentVerified();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) {
const corrected = await readVersionHistoryFromXml();
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
if (entry) {
entry.number = result.versionNumber;
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
await writeVersionHistoryToXml(corrected);
}
}
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
autoSaveService.clearUncommittedSinceVersion();
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
if (closeAfter) {
try {
await closeDocument(DocumentCloseMode.Save);
} catch (error) {
throw new VersionSavedButCloseFailedError(result, error);
}
}
return result;
} finally {
autoSaveService.resume();
}
}

View file

@ -0,0 +1,159 @@
/**
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
*
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
* - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ
* POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี
* segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId
* (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST
* /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
*/
import * as authService from "./authService";
import { generateGuid } from "./metadataService";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
interface StoredVersion {
record: VersionRecord;
docxBlob: Blob | null;
pdfBlob: Blob | null;
}
/** key = documentId, เก็บเรียงเก่า → ใหม่ */
const store = new Map<string, StoredVersion[]>();
const STORAGE_KEY = "wordaddin-mock-versions";
/** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */
function loadPersisted(): Record<string, VersionRecord[]> {
try {
if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage
const raw = window.localStorage.getItem(STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as Record<string, VersionRecord[]>) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
const persisted = loadPersisted();
for (const [documentId, records] of Object.entries(persisted)) {
if (Array.isArray(records)) {
store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null })));
}
}
/** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */
function persist(): void {
try {
const snapshot: Record<string, VersionRecord[]> = {};
store.forEach((versions, documentId) => {
snapshot[documentId] = versions.map((item) => item.record);
});
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// ignore — quota/private mode
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */
async function mockContentHash(file: Blob): Promise<string> {
try {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
} catch {
return "mock-hash";
}
}
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
* + FR-13: message คือ commit message ทางเลือก
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
export async function uploadVersion(
documentId: string,
file: Blob,
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string,
message?: string,
pdf?: Blob,
templateId?: string
): Promise<UploadVersionResult> {
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
void fileName;
void templateId;
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
const versions = store.get(documentId) ?? [];
const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1;
const hasPdf = !!pdf && pdf.size > 0;
const trimmedMessage = message?.trim();
const record: VersionRecord = {
versionId: generateGuid(),
versionNumber,
createdAt: new Date().toISOString(),
createdByUserId: authService.getUsername() ?? "unknown",
contentHash: await mockContentHash(file),
message: trimmedMessage || null,
hasPdf,
fields: fields && Object.keys(fields).length > 0 ? fields : null,
};
versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null });
store.set(documentId, versions);
persist();
return { hasPdf, versionId: record.versionId, versionNumber };
}
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
await delay(200);
const versions = store.get(documentId) ?? [];
return versions
.slice()
.reverse()
.map((item) => ({ ...item.record }));
}
export interface VersionDownloadLink {
url: string;
/** ISO-8601 เวลาที่ URL หมดอายุ */
expiresAt: string;
}
/**
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
* mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
* context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
* ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
* — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
*/
export async function getDownloadLink(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> {
void documentId;
void versionId;
void format;
await delay(150);
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
}
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}