Phase 9: version message (FR-13)

- ใส่คำอธิบายแบบ commit message ได้ทุก flow ที่สร้าง version (Save & Version / Start Versioning / generate จากเว็บ) + แสดงใน version history ทุกหน้า
- backend: DocumentVersion.Message (varchar 2048) + migration AddVersionMessage + BadRequest ตรวจความยาวก่อนแตะ MinIO (กัน 500 + orphan object)
- add-in: ConfirmVersionDialog เพิ่ม textarea (Start Versioning ใช้ dialog ด้วย) + แสดง message ใน VersionHistoryPanel
- web: ฟอร์มสร้างเอกสาร + หน้า detail แสดง message (desktop truncate + tooltip, mobile เต็มบรรทัด)
- sync เอกสารทั้งหมด: spec v5 (FR-13/AC-16) + plan Phase 9 + acceptance-review 1–16 + README + CLAUDE.md + web/README
- tests: backend 52 / addin 50 / web 36 — เขียวทั้งชุด

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-08-20 22:05:49 +07:00
parent b47667f918
commit 7b20f12ede
28 changed files with 928 additions and 141 deletions

View file

@ -14,13 +14,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–8 เสร็จ** (implement ครบ + tests ครบชุด: addin 47 / web 36 / backend integration 42 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–15 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md) **สถานะปัจจุบัน: Phase 0–9 เสร็จ** (implement ครบ + tests ครบชุด: addin 50 / web 36 / backend integration 52 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–16 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md)
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–12, acceptance criteria 1–15, data models, platform constraints) - `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–13, acceptance criteria 1–16, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC - `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 10 phases (0–9) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–15 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ - `docs/acceptance-review.md` — ผลตรวจ AC 1–16 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -39,12 +39,15 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up` 11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up`
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง) 12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว 13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
14. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ)
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ ## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
**Create → v1 (FR-10):** web form → `POST /api/documents/generate` → OpenXML แทนที่ค่าใน SDT + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ → สร้าง `Document` + `DocumentFieldValue` + upload v1 → ผู้ใช้โหลดไฟล์ไปเปิดใน Word → add-in ตรวจพบ tracked doc ทันทีเห็น v1 **Create → v1 (FR-10):** web form → `POST /api/documents/generate` → OpenXML แทนที่ค่าใน SDT + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ → สร้าง `Document` + `DocumentFieldValue` + upload v1 → ผู้ใช้โหลดไฟล์ไปเปิดใน Word → add-in ตรวจพบ tracked doc ทันทีเห็น v1
**Save & Version (FR-2):** ปุ่มใน task pane → stamp properties → อัปเดต XML part (delete-then-add, fail ได้แบบ silent) → `document.save()` → `getFileAsync` → POST version **พร้อม `fields` map จาก content controls (FR-11)** → backend update `DocumentFieldValue` (latest) + `FieldsSnapshotJson` (ต่อ version) **Save & Version (FR-2):** ปุ่มใน task pane → dialog ยืนยันพร้อมช่อง commit message ทางเลือก (FR-13) → stamp properties → อัปเดต XML part (delete-then-add, fail ได้แบบ silent) → `document.save()` → `getFileAsync` → POST version **พร้อม `fields` map จาก content controls (FR-11) + `message`** → backend update `DocumentFieldValue` (latest) + `FieldsSnapshotJson` (ต่อ version)
**Version message (FR-13):** commit message ทางเลือกต่อ version (≤2048, ว่าง/whitespace → null + trim) รับจากทุก flow ที่สร้าง version (upload multipart + generate JSON) — เก็บที่ `DocumentVersion.Message` เท่านั้น **ไม่ฝังลงไฟล์ docx** (backend คือ source of truth) · แสดงใน history ทุกหน้า (web ตาราง+การ์ดมือถือ, add-in panel) · เกิน 2048 → 400 ตรวจก่อนแตะ MinIO
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore **Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
@ -68,7 +71,7 @@ dotnet ef migrations add <Name>
# backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที) # backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที)
cd backend cd backend
dotnet test # slnx ครอบทั้ง API + tests (42 tests) dotnet test # slnx ครอบทั้ง API + tests (52 tests)
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
# add-in (https://localhost:3000) # add-in (https://localhost:3000)

View file

@ -10,6 +10,7 @@
- ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v1 ของเอกสารใหม่ **เอกสารต้นทางและ version history ไม่ถูกแตะ** - ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v1 ของเอกสารใหม่ **เอกสารต้นทางและ version history ไม่ถูกแตะ**
- 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history - 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history
- 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend) - 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend)
- 💬 **คำอธิบายต่อ version (commit message)** — พิมพ์ข้อความอธิบายการเปลี่ยนแปลงได้ทุกครั้งที่สร้าง version (Save & Version, Start Versioning, สร้างจากเว็บ) แล้วแสดงกับ version นั้นใน history ทุกหน้า — เว้นว่างได้
- 🔄 **Field sync** — ทุกครั้งที่สร้าง version ระบบ extract ค่าจาก content controls ของเอกสารไป update fields ใน DB ให้ sync กับเนื้อหาจริงเสมอ - 🔄 **Field sync** — ทุกครั้งที่สร้าง version ระบบ extract ค่าจาก content controls ของเอกสารไป update fields ใน DB ให้ sync กับเนื้อหาจริงเสมอ
- ⬇️ **ดาวน์โหลด version ย้อนหลัง** — ทุก version เก็บครบพร้อม SHA256 hash ตรวจสอบความถูกต้องได้ - ⬇️ **ดาวน์โหลด version ย้อนหลัง** — ทุก version เก็บครบพร้อม SHA256 hash ตรวจสอบความถูกต้องได้
@ -41,16 +42,20 @@
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด | | 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
| 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle | | 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle |
| 6.4 | รายการเอกสารต่อ template + document detail + re-generate (FR-12) | ✅ เสร็จ (เพิ่มทีหลัง v4) | | 6.4 | รายการเอกสารต่อ template + document detail + re-generate (FR-12) | ✅ เสร็จ (เพิ่มทีหลัง v4) |
| 7 | Tests ครบชุด (backend integration + web Vitest + addin Jest) | ✅ เสร็จ — 125 tests ผ่านทั้งหมด (ดูหัวข้อ Tests) | | 7 | Tests ครบชุด (backend integration + web Vitest + addin Jest) | ✅ เสร็จ — 125 tests ผ่านทั้งหมดตอนนั้น (ดูหัวข้อ Tests สำหรับตัวเลขปัจจุบัน) |
| 8 | ไล่ acceptance criteria เต็มชุด | ✅ เสร็จ — ผลครบ AC-1 ถึง 15 ที่ [docs/acceptance-review.md](docs/acceptance-review.md) | | 8 | ไล่ acceptance criteria เต็มชุด | ✅ เสร็จ — ผลครบ AC-1 ถึง 15 ที่ [docs/acceptance-review.md](docs/acceptance-review.md) |
| — | Web Portal redesign — design system (token layer สี/ฟอนต์/radius) + layout responsive ทุกหน้า | ✅ เสร็จ (นอก spec — แผนที่ [docs/plans/web-portal-redesign.md](docs/plans/web-portal-redesign.md)) |
| 9 | Version message — commit message ต่อ version ทุก flow (FR-13) | ✅ เสร็จ (เพิ่มทีหลัง v5 — แผนที่ [docs/plans/version-message.md](docs/plans/version-message.md)) |
**ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail), add-in ครบทั้ง services และ task pane UI ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) · **tests ครบชุด 125 ตัว** (backend integration 42 รันบน infra จริง + addin 47 + web 36) · ไล่ AC 1–15 ครบแล้ว — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้ (Word Online/Windows, click-through บน browser จริง — ดูท้าย [acceptance-review.md](docs/acceptance-review.md)) **ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail + commit message), add-in ครบทั้ง services และ task pane UI ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) บน design system + responsive layout ใหม่ · **tests ครบชุด 138 ตัว** (backend integration 52 รันบน infra จริง + addin 50 + web 36) · ไล่ AC 1–16 ครบแล้ว — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้ (Word Online/Windows, click-through บน browser จริง — ดูท้าย [acceptance-review.md](docs/acceptance-review.md))
## เอกสาร ## เอกสาร
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–12), data models, acceptance criteria (1–15) - [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–13), data models, acceptance criteria (1–16)
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done - [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 10 phases (0–9) พร้อม definition of done
- [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 15 ข้อ พร้อมปัญหาที่เจอและรายการที่เหลือต้องยืนยันด้วยมือ - [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 16 ข้อ พร้อมปัญหาที่เจอและรายการที่เหลือต้องยืนยันด้วยมือ
- [docs/plans/version-message.md](docs/plans/version-message.md) — แผน Phase 9: commit message ต่อ version (FR-13)
- [docs/plans/web-portal-redesign.md](docs/plans/web-portal-redesign.md) — แผน redesign web portal (design system + responsive, นอก spec)
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0) สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
@ -108,15 +113,15 @@ dotnet run # รันทั้ง http://localhost:5075 แ
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`): **API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
``` ```
# Documents / versions (FR-5–7, 11, 12) # Documents / versions (FR-5–7, 11, 12, 13)
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync + commit message
GET /api/documents/{documentId} # detail: ข้อมูลเอกสาร + template + ค่าล่าสุดทุก field (FR-12) GET /api/documents/{documentId} # detail: ข้อมูลเอกสาร + template + ค่าล่าสุดทุก field (FR-12)
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields) GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (snapshot fields + message ต่อ version)
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO) GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO) GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO)
# Generate จาก template (FR-10) # Generate จาก template (FR-10, 13)
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata POST /api/documents/generate # { templateId, values, message? } → เอกสาร v1 พร้อม metadata
# Templates (FR-9, 12) # Templates (FR-9, 12)
GET /api/templates # รายการ (?includeInactive=true สำหรับ admin) GET /api/templates # รายการ (?includeInactive=true สำหรับ admin)
@ -128,7 +133,7 @@ PUT /api/templates/{id}/file # แทนท
DELETE /api/templates/{id} # deactivate (soft-delete) DELETE /api/templates/{id} # deactivate (soft-delete)
``` ```
- Upload เป็น multipart/form-data: `file` (.docx เท่านั้น, จำกัด 100MB) + `clientVersionId` (GUID) + `fields` (optional, JSON map tag→text สำหรับ field sync) - Upload เป็น multipart/form-data: `file` (.docx เท่านั้น, จำกัด 100MB) + `clientVersionId` (GUID) + `fields` (optional, JSON map tag→text สำหรับ field sync) + `message` (optional, commit message ยาวไม่เกิน 2048 อักขระ — FR-13 · ว่าง/whitespace ล้วน = ไม่เก็บ)
- `documentId` ยังไม่มีในระบบ = สร้างใหม่อัตโนมัติ · `versionNumber` เพิ่มอัตโนมัติต่อเอกสาร · เก็บ SHA256 ของทุก version - `documentId` ยังไม่มีในระบบ = สร้างใหม่อัตโนมัติ · `versionNumber` เพิ่มอัตโนมัติต่อเอกสาร · เก็บ SHA256 ของทุก version
- Field sync policy (FR-11): field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request** · ส่งเกินนิยาม → ignore - Field sync policy (FR-11): field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request** · ส่งเกินนิยาม → ignore
- Generate (FR-10): backend เปิด template ด้วย OpenXML → แทนที่ค่าใน SDT ที่ **run level** (รักษา formatting) → stamp custom properties (`SavedByAddIn`, `DocumentTrackingId`) → upload เป็น v1 → response `{ documentId, versionId, versionNumber, downloadUrl }` — เปิดไฟล์ใน Word แล้ว add-in (Phase 4–5) จะรู้จักทันที - Generate (FR-10): backend เปิด template ด้วย OpenXML → แทนที่ค่าใน SDT ที่ **run level** (รักษา formatting) → stamp custom properties (`SavedByAddIn`, `DocumentTrackingId`) → upload เป็น v1 → response `{ documentId, versionId, versionNumber, downloadUrl }` — เปิดไฟล์ใน Word แล้ว add-in (Phase 4–5) จะรู้จักทันที
@ -180,12 +185,12 @@ curl -X PUT http://localhost:5075/api/templates/$TPL_ID \
{ "key": "salary", "label": "เงินเดือน", "type": "number", "isRequired": true, "sortOrder": 1 } ]}' { "key": "salary", "label": "เงินเดือน", "type": "number", "isRequired": true, "sortOrder": 1 } ]}'
``` ```
**3) Generate เอกสาร — ได้ .docx ที่เป็น version 1 พร้อม metadata:** **3) Generate เอกสาร — ได้ .docx ที่เป็น version 1 พร้อม metadata** (แนบ `message` ได้เลย — เป็น commit message ของ v1):
```bash ```bash
curl -X POST http://localhost:5075/api/documents/generate \ curl -X POST http://localhost:5075/api/documents/generate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"templateId\":\"$TPL_ID\",\"values\":{\"employee_name\":\"สมชาย ใจดี\",\"salary\":\"50000\"}}" -d "{\"templateId\":\"$TPL_ID\",\"values\":{\"employee_name\":\"สมชาย ใจดี\",\"salary\":\"50000\"},\"message\":\"ฉบับเสนอลงนามครั้งแรก\"}"
# → { "documentId": "...", "versionId": "...", "versionNumber": 1, "downloadUrl": "/api/documents/.../download" } # → { "documentId": "...", "versionId": "...", "versionNumber": 1, "downloadUrl": "/api/documents/.../download" }
curl -H "Authorization: Bearer $TOKEN" -o ไฟล์ที่สร้าง.docx \ curl -H "Authorization: Bearer $TOKEN" -o ไฟล์ที่สร้าง.docx \
@ -194,17 +199,18 @@ curl -H "Authorization: Bearer $TOKEN" -o ไฟล์ที่สร้าง.d
เปิดไฟล์ที่ได้ตรวจได้ว่า: ค่าถูกทุก field (formatting ของ template คงเดิม) และมี custom properties `SavedByAddIn` / `DocumentTrackingId` (unzip ดู `docProps/custom.xml` หรือ Word → File → Info → Properties → Advanced Properties) เปิดไฟล์ที่ได้ตรวจได้ว่า: ค่าถูกทุก field (formatting ของ template คงเดิม) และมี custom properties `SavedByAddIn` / `DocumentTrackingId` (unzip ดู `docProps/custom.xml` หรือ Word → File → Info → Properties → Advanced Properties)
**4) Field sync ตอนสร้าง version ถัดไป** — upload v2 พร้อมแนบ `fields` (map tag→text ที่ extract จาก content controls — ฝั่ง add-in ทำให้อัตโนมัติตอนกด Save & Version แล้ว): **4) Field sync ตอนสร้าง version ถัดไป** — upload v2 พร้อมแนบ `fields` (map tag→text ที่ extract จาก content controls — ฝั่ง add-in ทำให้อัตโนมัติตอนกด Save & Version แล้ว) และ `message` (commit message — FR-13):
```bash ```bash
curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-F "file=@ไฟล์แก้แล้ว.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \ -F "file=@ไฟล์แก้แล้ว.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \
-F "clientVersionId=$(uuidgen)" \ -F "clientVersionId=$(uuidgen)" \
-F 'fields={"employee_name":"สมชาย ใจดี มากกว่าเดิม","salary":"60000"}' -F 'fields={"employee_name":"สมชาย ใจดี มากกว่าเดิม","salary":"60000"}' \
-F "message=ปรับเงินเดือนตามผลประเมิน"
``` ```
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error) แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields และ `message` ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
### 3) Add-in (ครบทั้ง services layer และ task pane UI) ### 3) Add-in (ครบทั้ง services layer และ task pane UI)
@ -212,7 +218,7 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
cd addin cd addin
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost) npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
npm run build # webpack build + type-check ทั้ง program npm run build # webpack build + type-check ทั้ง program
npm test # Jest — unit tests (47 ตัว) npm test # Jest — unit tests (50 ตัว)
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์) npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
npm run validate # ตรวจ manifest.xml npm run validate # ตรวจ manifest.xml
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
@ -226,8 +232,8 @@ npm start # dev server https://localhost:3000 — sid
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) | | `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ | | `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in | | `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) | | `versioningService.ts` | uploadVersion (แนบ `fields` + `message`), getVersions, downloadVersion + getDownloadLink (presigned) |
| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec | | `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec + ส่ง commit message (FR-13) |
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json) หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
@ -243,10 +249,10 @@ npm run build
**Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`: **Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`:
- **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document** - **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document** (กดแล้วเปิด dialog ยืนยันพร้อมช่องคำอธิบายก่อนสร้าง v1 — FR-13)
- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection - **Save & Version (FR-2)** — confirm dialog พร้อมช่อง **คำอธิบาย version แบบ commit message** ทางเลือก (FR-13) → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection
- **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block) - **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block)
- **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์) - **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + คำอธิบาย version (ถ้ามี) + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์)
- **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ - **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ
**Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน): **Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน):
@ -272,28 +278,30 @@ cd web && npm test # Vitest — unit tests (36 ตัว)
| `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที | | `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที |
| `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) | | `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) |
| `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail | | `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail |
| `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) → สร้างเป็น v1 + ดาวน์โหลด | | `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v1 + ดาวน์โหลด |
| `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history (กดขยายแถวเห็นค่า fields ตอนนั้น + ดาวน์โหลดต่อ version) + ปุ่ม **⟳ re-generate** | | `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history พร้อมคำอธิบายของแต่ละ version (กดขยายแถวเห็นค่า fields ตอนนั้น + ดาวน์โหลดต่อ version) + ปุ่ม **⟳ re-generate** |
**สร้าง template แรกจากไฟล์จริง:** เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) → หน้า `/templates/new` upload ไฟล์ → ระบบ detect tags ออกมาเป็นรายการ fields → กด "ตั้งค่าต่อ" ไปใส่ label/type/required ให้เรียบร้อย → กลับมาหน้าหลักเพื่อสร้างเอกสาร **สร้าง template แรกจากไฟล์จริง:** เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) → หน้า `/templates/new` upload ไฟล์ → ระบบ detect tags ออกมาเป็นรายการ fields → กด "ตั้งค่าต่อ" ไปใส่ label/type/required ให้เรียบร้อย → กลับมาหน้าหลักเพื่อสร้างเอกสาร
**Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน → กดสร้าง → ได้**เอกสารใหม่เป็น v1** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล) **Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน (ช่องคำอธิบาย version ว่าง — เป็นเอกสารใหม่ได้ข้อความใหม่) → กดสร้าง → ได้**เอกสารใหม่เป็น v1** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล)
Theme toggle (light/dark) อยู่มุมขวาบนของทุกหน้า — จำค่าไว้หลัง reload Theme toggle (light/dark) อยู่มุมขวาบนของทุกหน้า (header ติด sticky) — จำค่าไว้หลัง reload
**Design system + responsive (redesign ล่าสุด):** สี/ฟอนต์/radius/shadow ทั้งหมดเป็น token ผ่าน CSS variables ใน [web/app/assets/css/tailwind.css](web/app/assets/css/tailwind.css) (derive จาก scheme ของ admincourt.go.th — ฟอนต์ Sukhumvit Set/Sarabun, primary `#0000EE`, elevated ครีม) + component classes กลาง (`.btn*`/`.input`/`.card`/`.table-card` ฯลฯ) · ทุกหน้า responsive ครบ — ตารางทุกตัวเปลี่ยนเป็น card list บนจอเล็กกว่า `md`, sticky header + backdrop blur, ฟอร์ม stack บนมือถือ
**Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID` **Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID`
## Tests ## Tests
ชุด test ทั้งหมดของระบบ — รวม **125 ตัว** ครอบทุก flow ตาม acceptance criteria: ชุด test ทั้งหมดของระบบ — รวม **138 ตัว** ครอบทุก flow ตาม acceptance criteria:
```bash ```bash
# 1) backend integration (42 ตัว) — ต้องมี Docker รันอยู่ # 1) backend integration (52 ตัว) — ต้องมี Docker รันอยู่
cd backend cd backend
dotnet test dotnet test
dotnet test --filter "FullyQualifiedName~FieldSyncTests" # รันเฉพาะชุด (Auth/UploadVersion/Template/Generate/FieldSync) dotnet test --filter "FullyQualifiedName~FieldSyncTests" # รันเฉพาะชุด (Auth/UploadVersion/Template/Generate/FieldSync/VersionMessage)
# 2) add-in unit (47 ตัว) — mock Word.run / Office.context.ui / fetch / Keycloak # 2) add-in unit (50 ตัว) — mock Word.run / Office.context.ui / fetch / Keycloak
cd addin && npm test cd addin && npm test
npx jest src/services/versionFlow.test.ts # รันเดี่ยว npx jest src/services/versionFlow.test.ts # รันเดี่ยว
@ -309,12 +317,13 @@ npx vitest run test/useTheme.spec.ts # รันเดี
- **TemplateTests** — detect SDT tags ครบทุกรูปแบบ (run เดี่ยว/multi-run/block/tag ซ้ำ) + บันทึก field defs + replace ไฟล์ (tag เพิ่ม/หาย) + deactivate แล้ว generate ไม่ได้แต่เอกสารเก่ายังใช้ได้ (AC-11) - **TemplateTests** — detect SDT tags ครบทุกรูปแบบ (run เดี่ยว/multi-run/block/tag ซ้ำ) + บันทึก field defs + replace ไฟล์ (tag เพิ่ม/หาย) + deactivate แล้ว generate ไม่ได้แต่เอกสารเก่ายังใช้ได้ (AC-11)
- **GenerateTests** — generate แล้วค่าครบทุก field + multi-run รวมเป็น run เดียวรักษา formatting + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ + v1 + field values ใน DB + required/type validation + FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template) (AC-12, 15) - **GenerateTests** — generate แล้วค่าครบทุก field + multi-run รวมเป็น run เดียวรักษา formatting + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ + v1 + field values ใน DB + required/type validation + FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template) (AC-12, 15)
- **FieldSyncTests** — Save & Version พร้อม `fields`: latest values อัปเดต + snapshot ต่อ version (AC-13) · ลบ content control → request สำเร็จ ค่าเดิมคงอยู่ + mark missing (AC-14) - **FieldSyncTests** — Save & Version พร้อม `fields`: latest values อัปเดต + snapshot ต่อ version (AC-13) · ลบ content control → request สำเร็จ ค่าเดิมคงอยู่ + mark missing (AC-14)
- **VersionMessageTests** — commit message ต่อ version (FR-13/AC-16): เก็บ + echo ใน history · ว่าง/whitespace → null · trim · เกิน 2048 → 400 ไม่มี side effect · ยาวพอดี 2048 ผ่าน · message ต่อ version เป็นของตัวเอง · generate v1 พร้อม message · generate oversize → 400 ก่อนแตะ MinIO
ไฟล์ .docx ที่ใช้ตอน test สร้างใน memory ด้วย OpenXML (`TestDocx.cs`) — ไม่มี binary ติดใน repo · realm ของ Keycloak ทดสอบเป็นสำเนาของ dev (`Fixtures/realm-word-versioning-test.json`) ที่เปิด password grant ให้ client `word-versioning-web` เฉพาะตอน test เพื่อขอ token ได้โดยไม่ต้องเดิน PKCE ไฟล์ .docx ที่ใช้ตอน test สร้างใน memory ด้วย OpenXML (`TestDocx.cs`) — ไม่มี binary ติดใน repo · realm ของ Keycloak ทดสอบเป็นสำเนาของ dev (`Fixtures/realm-word-versioning-test.json`) ที่เปิด password grant ให้ client `word-versioning-web` เฉพาะตอน test เพื่อขอ token ได้โดยไม่ต้องเดิน PKCE
**web unit** (`web/test/`) — `fieldValidation.spec.ts` (validate ฟอร์มสร้างเอกสาร: required/number/date ครบ edge cases) · `useTheme.spec.ts` (toggle + persist + system default) · `snapshot.spec.ts` (`normalizeSnapshot` รองรับ snapshot ทั้ง 2 รูปแบบที่หน้า detail ใช้) — รันบน happy-dom โดย stub Nuxt auto-imports เบาๆ (`test/setup.ts`) **web unit** (`web/test/`) — `fieldValidation.spec.ts` (validate ฟอร์มสร้างเอกสาร: required/number/date ครบ edge cases) · `useTheme.spec.ts` (toggle + persist + system default) · `snapshot.spec.ts` (`normalizeSnapshot` รองรับ snapshot ทั้ง 2 รูปแบบที่หน้า detail ใช้) — รันบน happy-dom โดย stub Nuxt auto-imports เบาๆ (`test/setup.ts`)
**addin unit** (`addin/src/services/*.test.ts`) — ตามที่มาจาก Phase 4–5: PKCE flow, metadata stamp + XML part fail-แบบ-ไม่-throw, apiClient retry-on-401, fileService ประกอบ slice ครบ 4 รูปแบบ, versionFlow 7 ขั้น **addin unit** (`addin/src/services/*.test.ts`) — ตามที่มาจาก Phase 4–5: PKCE flow, metadata stamp + XML part fail-แบบ-ไม่-throw, apiClient retry-on-401, fileService ประกอบ slice ครบ 4 รูปแบบ, versionFlow 7 ขั้น + ส่ง commit message ของทั้ง Save & Version และ Start Versioning (FR-13)
## Troubleshooting (จากการทดสอบจริงบน Word for Mac) ## Troubleshooting (จากการทดสอบจริงบน Word for Mac)

View file

@ -32,6 +32,8 @@ export interface VersionRecord {
/** SHA256 hex */ /** SHA256 hex */
contentHash: string; contentHash: string;
fields: FieldsSnapshot | null; fields: FieldsSnapshot | null;
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
message: string | null;
} }
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */ /** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */

View file

@ -117,6 +117,20 @@ describe("startVersioning (FR-1 / AC-5)", () => {
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(String((form.get("file") as File).name)).toMatch(/\.docx$/); expect(String((form.get("file") as File).name)).toMatch(/\.docx$/);
}); });
it("FR-13 — ส่ง message แนบไปกับ multipart form ของ v1", async () => {
installWordMock({});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
);
await startVersioning("เริ่มติดตามฉบับแรก");
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก");
});
}); });
describe("saveAndVersion (FR-2 / AC-7, 13)", () => { describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
@ -213,4 +227,42 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
versionNumber: 2, versionNumber: 2,
}); });
}); });
it("FR-13 — ส่ง message แนบไปกับ multipart form", async () => {
installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
},
});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
);
await saveAndVersion("11111111-1111-1111-1111-111111111111", 1, "แก้ไขสัญญาข้อ 3");
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(String(form.get("message"))).toBe("แก้ไขสัญญาข้อ 3");
});
it("FR-13 — message ว่าง/whitespace ล้วน → ไม่แนบฟิลด์ message เลย", async () => {
installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
},
});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
);
await saveAndVersion("11111111-1111-1111-1111-111111111111", 1, " ");
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(form.get("message")).toBeNull();
});
}); });

View file

@ -70,15 +70,18 @@ async function saveDocument(): Promise<void> {
async function uploadCurrentFile( async function uploadCurrentFile(
documentId: string, documentId: string,
clientVersionId: string, clientVersionId: string,
fields: Record<string, string> fields: Record<string, string>,
message?: string
): Promise<{ versionId: string; versionNumber: number }> { ): Promise<{ versionId: string; versionNumber: number }> {
const file = await getCurrentDocumentFile(); const file = await getCurrentDocumentFile();
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName()); return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName(), message);
} }
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */ /** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน) */
export async function startVersioning( export async function startVersioning(
onProgress?: (message: string) => void message?: string,
onProgress?: (progress: string) => void
): Promise<StartVersioningResult> { ): Promise<StartVersioningResult> {
const documentId = generateGuid(); const documentId = generateGuid();
const clientVersionId = generateGuid(); const clientVersionId = generateGuid();
@ -106,15 +109,17 @@ export async function startVersioning(
// 4) upload version แรก // 4) upload version แรก
onProgress?.("กำลังอัปโหลด version 1…"); onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields); const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
return { documentId, ...result }; return { documentId, ...result };
} }
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */ /** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว)
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน) */
export async function saveAndVersion( export async function saveAndVersion(
documentId: string, documentId: string,
latestVersionNumber: number, latestVersionNumber: number,
onProgress?: (message: string) => void message?: string,
onProgress?: (progress: string) => void
): Promise<SaveAndVersionResult> { ): Promise<SaveAndVersionResult> {
const clientVersionId = generateGuid(); const clientVersionId = generateGuid();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
@ -142,7 +147,7 @@ export async function saveAndVersion(
// 4) upload // 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…"); onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields); const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) { if (result.versionNumber !== predictedNumber) {

View file

@ -8,13 +8,15 @@ import { generateGuid } from "./metadataService";
* `fields` = map จาก `readContentControlFields()` ตาม FR-11 * `fields` = map จาก `readContentControlFields()` ตาม FR-11
*/ */
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) */ /** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี)
* + FR-13: แนบ commit message ทางเลือกของผู้ใช้ */
export async function uploadVersion( export async function uploadVersion(
documentId: string, documentId: string,
file: Blob, file: Blob,
clientVersionId: string, clientVersionId: string,
fields?: Record<string, string>, fields?: Record<string, string>,
fileName?: string fileName?: string,
message?: string
): Promise<UploadVersionResult> { ): Promise<UploadVersionResult> {
const form = new FormData(); const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`); form.append("file", file, fileName || `${documentId}.docx`);
@ -22,6 +24,10 @@ export async function uploadVersion(
if (fields && Object.keys(fields).length > 0) { if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields)); form.append("fields", JSON.stringify(fields));
} }
const trimmedMessage = message?.trim();
if (trimmedMessage) {
form.append("message", trimmedMessage);
}
return apiSendExpectJson<UploadVersionResult>( return apiSendExpectJson<UploadVersionResult>(
`/api/documents/${documentId}/versions`, `/api/documents/${documentId}/versions`,
{ method: "POST", body: form }, { method: "POST", body: form },

View file

@ -87,6 +87,7 @@ const App: React.FC = () => {
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [progressMessage, setProgressMessage] = React.useState<string | null>(null); const [progressMessage, setProgressMessage] = React.useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = React.useState(false); const [confirmOpen, setConfirmOpen] = React.useState(false);
const [startConfirmOpen, setStartConfirmOpen] = React.useState(false);
const [flowError, setFlowError] = React.useState<string | null>(null); const [flowError, setFlowError] = React.useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null); const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
@ -209,13 +210,13 @@ const App: React.FC = () => {
setPhase("signed-out"); setPhase("signed-out");
}; };
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */ /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) */
const handleStartVersioning = async () => { const handleStartVersioning = async (message: string) => {
setStarting(true); setStarting(true);
setFlowError(null); setFlowError(null);
setFlowSuccess(null); setFlowSuccess(null);
try { try {
const result = await startVersioning(setProgressMessage); const result = await startVersioning(message, setProgressMessage);
setDocumentId(result.documentId); setDocumentId(result.documentId);
setPhase("tracking"); setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
@ -230,8 +231,9 @@ const App: React.FC = () => {
} }
}; };
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */ /** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
const handleSaveAndVersion = async () => { * message คือ commit message จาก dialog (FR-13) */
const handleSaveAndVersion = async (message: string) => {
if (!documentId) { if (!documentId) {
return; return;
} }
@ -240,7 +242,7 @@ const App: React.FC = () => {
setFlowSuccess(null); setFlowSuccess(null);
try { try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0; const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, setProgressMessage); const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage);
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`); setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
await refreshHistory(documentId); await refreshHistory(documentId);
} catch (error) { } catch (error) {
@ -349,7 +351,7 @@ const App: React.FC = () => {
appearance="primary" appearance="primary"
icon={<DocumentArrowUp24Regular />} icon={<DocumentArrowUp24Regular />}
disabled={starting} disabled={starting}
onClick={() => void handleStartVersioning()} onClick={() => setStartConfirmOpen(true)}
> >
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"} {starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
</Button> </Button>
@ -422,9 +424,20 @@ const App: React.FC = () => {
<ConfirmVersionDialog <ConfirmVersionDialog
open={confirmOpen} open={confirmOpen}
busy={saving} busy={saving}
onConfirm={() => void handleSaveAndVersion()} onConfirm={(message) => void handleSaveAndVersion(message)}
onDismiss={() => setConfirmOpen(false)} onDismiss={() => setConfirmOpen(false)}
/> />
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
<ConfirmVersionDialog
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description="ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => void handleStartVersioning(message)}
onDismiss={() => setStartConfirmOpen(false)}
/>
</div> </div>
); );
}; };

View file

@ -7,31 +7,68 @@ import {
DialogContent, DialogContent,
DialogSurface, DialogSurface,
DialogTitle, DialogTitle,
Field,
Spinner, Spinner,
Textarea,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
interface ConfirmVersionDialogProps { interface ConfirmVersionDialogProps {
open: boolean; open: boolean;
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */ /** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งสอง disable + แสดง spinner */
busy: boolean; busy: boolean;
onConfirm: () => void; /** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
title?: string;
description?: React.ReactNode;
confirmLabel?: string;
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
onConfirm: (message: string) => void;
onDismiss: () => void; onDismiss: () => void;
} }
/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" */ const DEFAULT_TITLE = "สร้าง version ใหม่?";
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมช่องคำอธิบาย version
* แบบ commit message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning */
export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({ export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({
open, open,
busy, busy,
title,
description,
confirmLabel,
onConfirm, onConfirm,
onDismiss, onDismiss,
}) => { }) => {
const [message, setMessage] = React.useState("");
// component ถูก mount ค้างไว้ตลอด (App render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
React.useEffect(() => {
if (!open) {
setMessage("");
}
}, [open]);
return ( return (
<Dialog open={open} onOpenChange={(_event, data) => !data.open && !busy && onDismiss()} modalType="alert"> <Dialog open={open} onOpenChange={(_event, data) => !data.open && !busy && onDismiss()} modalType="alert">
<DialogSurface> <DialogSurface>
<DialogBody> <DialogBody>
<DialogTitle>สร้าง version ใหม่?</DialogTitle> <DialogTitle>{title ?? DEFAULT_TITLE}</DialogTitle>
<DialogContent> <DialogContent>
ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้ {description ?? DEFAULT_DESCRIPTION}
<Field label="คำอธิบาย version (ไม่บังคับ)" style={{ marginTop: 12 }}>
<Textarea
value={message}
onChange={(_event, data) => setMessage(data.value)}
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
maxLength={2048}
resize="vertical"
rows={3}
disabled={busy}
textarea={{ "aria-label": "คำอธิบาย version" }}
/>
</Field>
{busy ? ( {busy ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
<Spinner size="tiny" /> กำลังดำเนินการ… <Spinner size="tiny" /> กำลังดำเนินการ…
@ -42,8 +79,8 @@ export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({
<Button appearance="secondary" disabled={busy} onClick={onDismiss}> <Button appearance="secondary" disabled={busy} onClick={onDismiss}>
ยกเลิก ยกเลิก
</Button> </Button>
<Button appearance="primary" disabled={busy} onClick={onConfirm}> <Button appearance="primary" disabled={busy} onClick={() => onConfirm(message)}>
สร้าง version {confirmLabel ?? DEFAULT_CONFIRM_LABEL}
</Button> </Button>
</DialogActions> </DialogActions>
</DialogBody> </DialogBody>

View file

@ -55,6 +55,12 @@ const useStyles = makeStyles({
color: tokens.colorNeutralForeground3, color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200, fontSize: tokens.fontSizeBase200,
}, },
messageRow: {
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
},
}); });
function formatTimestamp(iso: string): string { function formatTimestamp(iso: string): string {
@ -109,6 +115,9 @@ export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
</Badge> </Badge>
<span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span> <span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span>
<span className={styles.metaRow}>โดย {version.createdByUserId}</span> <span className={styles.metaRow}>โดย {version.createdByUserId}</span>
{version.message ? (
<span className={styles.messageRow}>{version.message}</span>
) : null}
</div> </div>
<Button <Button
icon={<ArrowDownload24Regular />} icon={<ArrowDownload24Regular />}

View file

@ -9,7 +9,7 @@ public record UploadVersionResult(Guid VersionId, int VersionNumber);
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl); public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields); public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder); public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder);
@ -29,9 +29,9 @@ public record DocDetail(Guid Id, string FileName, DateTimeOffset CreatedAt, Guid
public static class ApiExtensions public static class ApiExtensions
{ {
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option)</summary> /// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)</summary>
public static MultipartFormDataContent VersionForm( public static MultipartFormDataContent VersionForm(
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null) byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null)
{ {
var form = new MultipartFormDataContent(); var form = new MultipartFormDataContent();
var file = new ByteArrayContent(docx); var file = new ByteArrayContent(docx);
@ -40,6 +40,8 @@ public static class ApiExtensions
form.Add(new StringContent(clientVersionId), "clientVersionId"); form.Add(new StringContent(clientVersionId), "clientVersionId");
if (fieldsJson is not null) if (fieldsJson is not null)
form.Add(new StringContent(fieldsJson), "fields"); form.Add(new StringContent(fieldsJson), "fields");
if (message is not null)
form.Add(new StringContent(message), "message");
return form; return form;
} }

View file

@ -46,12 +46,13 @@ public static class TestFlow
public static Task<GenerateResult> GenerateDocumentAsync(HttpClient client, Guid templateId) => public static Task<GenerateResult> GenerateDocumentAsync(HttpClient client, Guid templateId) =>
GenerateDocumentAsync(client, templateId, DefaultValues); GenerateDocumentAsync(client, templateId, DefaultValues);
/// <summary>generate พร้อมค่าที่กำหนดเอง (ทดสอบ validation หรือค่าเกินนิยาม)</summary> /// <summary>generate พร้อมค่าที่กำหนดเอง (ทดสอบ validation หรือค่าเกินนิยาม)
/// + commit message ทางเลือก (FR-13)</summary>
public static async Task<GenerateResult> GenerateDocumentAsync( public static async Task<GenerateResult> GenerateDocumentAsync(
HttpClient client, Guid templateId, Dictionary<string, string> values) HttpClient client, Guid templateId, Dictionary<string, string> values, string? message = null)
{ {
using var response = await client.PostAsJsonAsync( using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json); "api/documents/generate", new { templateId, values, message }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json))!; return (await response.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json))!;
} }

View file

@ -0,0 +1,182 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-13 — commit message ต่อ version: รับจากทุก flow ที่สร้าง version (upload + generate),
/// normalize ว่าง/whitespace → null, trim, จำกัด 2048 อักขระ, echo ใน history (AC-16)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class VersionMessageTests(IntegrationTestFixture fixture)
{
[Fact]
public async Task Upload_WithMessage_PersistsRow_AndEchoesInHistory()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
message: "แก้ไขข้อ 3 หลังตรวจสอบ"));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
Assert.Equal("แก้ไขข้อ 3 หลังตรวจสอบ", version.Message);
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
Assert.Equal("แก้ไขข้อ 3 หลังตรวจสอบ", items!.Single().Message);
}
/// <summary>ไม่ส่ง / ส่งค่าว่าง / ส่ง whitespace ล้วน → เก็บ null ทั้ง DB และ history</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Upload_BlankOrMissingMessage_StoredAsNull(string? sent)
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
message: sent));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
Assert.Null(version.Message);
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
Assert.Null(items!.Single().Message);
}
[Fact]
public async Task Upload_TrimsSurroundingWhitespace()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
message: " แก้ไขข้อ 3 "));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
Assert.Equal("แก้ไขข้อ 3", version.Message);
}
[Fact]
public async Task Upload_OversizeMessage_Returns400_NoSideEffect()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
message: new string('ก', 2049)));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.Id == documentId)); // ไม่มี side effect
}
[Fact]
public async Task Upload_MessageExactly2048_Succeeds()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
var message = new string('ก', 2048);
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
message: message));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
Assert.Equal(message, version.Message);
}
[Fact]
public async Task SecondUpload_KeepsPerVersionMessages()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var first = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("v1"), "doc.docx", Guid.NewGuid().ToString(),
message: "ฉบับแรก"));
using var second = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("v2"), "doc.docx", Guid.NewGuid().ToString(),
message: "แก้ไขเงินเดือน"));
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
// history ใหม่ → เก่า พร้อม message ของแต่ละ version เป็นของตัวเอง
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
Assert.Equal(2, items!.Count);
Assert.Equal(2, items[0].VersionNumber);
Assert.Equal("แก้ไขเงินเดือน", items[0].Message);
Assert.Equal(1, items[1].VersionNumber);
Assert.Equal("ฉบับแรก", items[1].Message);
}
[Fact]
public async Task Generate_WithMessage_CreatesV1WithMessage()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen");
var generated = await TestFlow.GenerateDocumentAsync(
client, templateId, TestFlow.DefaultValues, message: "ฉบับเสนอลงนามครั้งแรก");
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking()
.SingleAsync(v => v.DocumentId == generated.DocumentId);
Assert.Equal(1, version.VersionNumber);
Assert.Equal("ฉบับเสนอลงนามครั้งแรก", version.Message);
using var history = await client.GetAsync($"api/documents/{generated.DocumentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
Assert.Equal("ฉบับเสนอลงนามครั้งแรก", items!.Single().Message);
}
/// <summary>400 ต้องเกิดก่อนแตะ MinIO/template — ไม่ทิ้ง orphan object ใน bucket</summary>
[Fact]
public async Task Generate_OversizeMessage_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen-oversize");
using var response = await client.PostAsJsonAsync(
"api/documents/generate",
new { templateId, values = TestFlow.DefaultValues, message = new string('ก', 2049) },
IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View file

@ -34,6 +34,7 @@ public class DocumentsController(
private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec
private const int MaxVersionInsertRetries = 5; private const int MaxVersionInsertRetries = 5;
private const int MaxVersionMessageLength = 2048; // FR-13 — ตรงกับ HasMaxLength ใน AppDbContext
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี) /// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)
/// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary> /// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary>
@ -59,6 +60,11 @@ public class DocumentsController(
if (!FieldMapJson.TryParse(request.Fields, out var fieldMap, out var fieldsError)) if (!FieldMapJson.TryParse(request.Fields, out var fieldMap, out var fieldsError))
return BadRequest(fieldsError); return BadRequest(fieldsError);
// FR-13 — commit message ทางเลือก (เช็คก่อนแตะ DB/storage กัน 500 จากข้อจำกัดความยาว)
var message = NormalizeMessage(request.Message);
if (message is { Length: > MaxVersionMessageLength })
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var fileName = request.File.FileName; var fileName = request.File.FileName;
@ -131,6 +137,7 @@ public class DocumentsController(
CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
CreatedAt = now, CreatedAt = now,
FieldsSnapshotJson = fieldsSnapshotJson, FieldsSnapshotJson = fieldsSnapshotJson,
Message = message, // FR-13
}; };
db.DocumentVersions.Add(version); db.DocumentVersions.Add(version);
@ -212,6 +219,11 @@ public class DocumentsController(
if (!FieldMapJson.TryNormalize(request.Values, out var values, out var valuesError)) if (!FieldMapJson.TryNormalize(request.Values, out var values, out var valuesError))
return BadRequest($"`values` ผิด format: {valuesError}"); return BadRequest($"`values` ผิด format: {valuesError}");
// FR-13 — เช็คก่อนทำ MinIO/template IO ใดๆ (กัน 500 + orphan object ใน bucket เมื่อ DB reject ทีหลัง)
var message = NormalizeMessage(request.Message);
if (message is { Length: > MaxVersionMessageLength })
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
// validate required + type (FR-10) — field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11 // validate required + type (FR-10) — field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11
var missingRequired = fieldDefs var missingRequired = fieldDefs
.Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty))) .Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty)))
@ -297,6 +309,7 @@ public class DocumentsController(
ContentHash = contentHash, ContentHash = contentHash,
CreatedByUserId = currentUser.UserId, CreatedByUserId = currentUser.UserId,
CreatedAt = now, CreatedAt = now,
Message = message, // FR-13
}; };
db.DocumentVersions.Add(version); db.DocumentVersions.Add(version);
@ -420,12 +433,13 @@ public class DocumentsController(
v.CreatedByUserId, v.CreatedByUserId,
v.ContentHash, v.ContentHash,
v.FieldsSnapshotJson, v.FieldsSnapshotJson,
v.Message,
}) })
.ToListAsync(ct); .ToListAsync(ct);
return Ok(rows.Select(r => new VersionListItem( return Ok(rows.Select(r => new VersionListItem(
r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash, r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash,
ParseSnapshot(r.FieldsSnapshotJson))).ToList()); ParseSnapshot(r.FieldsSnapshotJson), r.Message)).ToList());
} }
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary> /// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary>
@ -535,4 +549,9 @@ public class DocumentsController(
private static bool IsUniqueViolation(DbUpdateException ex) => private static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
/// <summary>FR-13 — normalize commit message: ว่าง/whitespace ล้วน → null, ไม่เช่นนั้น trim
/// (นโยบายเดียวกับ Template.Description ใน TemplatesController)</summary>
private static string? NormalizeMessage(string? message) =>
string.IsNullOrWhiteSpace(message) ? null : message.Trim();
} }

View file

@ -40,6 +40,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
entity.Property(x => x.ObjectKey).HasMaxLength(1024); entity.Property(x => x.ObjectKey).HasMaxLength(1024);
entity.Property(x => x.ContentHash).HasMaxLength(64); entity.Property(x => x.ContentHash).HasMaxLength(64);
entity.Property(x => x.Message).HasMaxLength(2048); // FR-13 commit message — นโยบายเดียวกับ Template.Description
entity.HasOne(x => x.Document) entity.HasOne(x => x.Document)
.WithMany(d => d.Versions) .WithMany(d => d.Versions)

View file

@ -0,0 +1,256 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using WordVersioning.Api.Data;
#nullable disable
namespace WordVersioning.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260820144855_AddVersionMessage")]
partial class AddVersionMessage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AddInSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("Documents");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
{
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldKey")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("SourceVersionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("DocumentId", "FieldKey");
b.ToTable("DocumentFieldValues");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldsSnapshotJson")
.HasColumnType("text");
b.Property<string>("Message")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("VersionNumber")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DocumentId", "VersionNumber")
.IsUnique();
b.ToTable("DocumentVersions");
});
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Templates");
});
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.HasKey("Id");
b.HasIndex("TemplateId", "Key")
.IsUnique();
b.ToTable("TemplateFields");
});
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.HasOne("WordVersioning.Api.Models.Template", "Template")
.WithMany("Documents")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Template");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
{
b.HasOne("WordVersioning.Api.Models.Document", null)
.WithMany()
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.HasOne("WordVersioning.Api.Models.Document", "Document")
.WithMany("Versions")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
{
b.HasOne("WordVersioning.Api.Models.Template", "Template")
.WithMany("Fields")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.Navigation("Versions");
});
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
{
b.Navigation("Documents");
b.Navigation("Fields");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace WordVersioning.Api.Migrations
{
/// <inheritdoc />
public partial class AddVersionMessage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Message",
table: "DocumentVersions",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Message",
table: "DocumentVersions");
}
}
}

View file

@ -103,6 +103,10 @@ namespace WordVersioning.Api.Migrations
b.Property<string>("FieldsSnapshotJson") b.Property<string>("FieldsSnapshotJson")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Message")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ObjectKey") b.Property<string>("ObjectKey")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)

View file

@ -25,4 +25,7 @@ public class DocumentVersion
/// <summary>Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON</summary> /// <summary>Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON</summary>
public string? FieldsSnapshotJson { get; set; } public string? FieldsSnapshotJson { get; set; }
/// <summary>คำอธิบาย version จากผู้ใช้ แบบ git commit message (FR-13) — optional ยาวสูงสุด 2048</summary>
public string? Message { get; set; }
} }

View file

@ -15,6 +15,9 @@ public class UploadVersionRequest
/// <summary>JSON object map tag→text จาก content controls (FR-11)</summary> /// <summary>JSON object map tag→text จาก content controls (FR-11)</summary>
public string? Fields { get; set; } public string? Fields { get; set; }
/// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary>
public string? Message { get; set; }
} }
public record UploadVersionResponse(Guid VersionId, int VersionNumber); public record UploadVersionResponse(Guid VersionId, int VersionNumber);
@ -27,18 +30,22 @@ public class GenerateDocumentRequest
/// <summary>map fieldKey → value (ค่า scalar ทั้งหมด)</summary> /// <summary>map fieldKey → value (ค่า scalar ทั้งหมด)</summary>
public JsonElement Values { get; set; } public JsonElement Values { get; set; }
/// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary>
public string? Message { get; set; }
} }
public record GenerateDocumentResponse(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl); public record GenerateDocumentResponse(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
/// <summary>item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี)</summary> /// <summary>item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี), message คือคำอธิบายของผู้ใช้ (FR-13)</summary>
public record VersionListItem( public record VersionListItem(
Guid VersionId, Guid VersionId,
int VersionNumber, int VersionNumber,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
string CreatedByUserId, string CreatedByUserId,
string ContentHash, string ContentHash,
object? Fields); object? Fields,
string? Message);
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary> /// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt); public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);

View file

@ -1,10 +1,10 @@
# ตรวจ Acceptance Criteria 1–15 (Phase 8) # ตรวจ Acceptance Criteria 1–16 (Phase 8 + 9)
> วันที่ตรวจ: 2026-08-20 · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10 > วันที่ตรวจ: 2026-08-20 · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
> >
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน > สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
สรุปรวม: **AC ทั้ง 15 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร สรุปรวม: **AC ทั้ง 16 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร
## ผลทีละข้อ ## ผลทีละข้อ
@ -65,12 +65,18 @@
- Re-generate: ผ่าน composition เดิม (detail → prefill → generate) — ตรวจ curl จริง Phase 6.4 ว่า **versionCount ของเอกสารต้นทางคงเดิม** + `TemplateTests.Deactivate_...` ยืนยัน policy deactivate - Re-generate: ผ่าน composition เดิม (detail → prefill → generate) — ตรวจ curl จริง Phase 6.4 ว่า **versionCount ของเอกสารต้นทางคงเดิม** + `TemplateTests.Deactivate_...` ยืนยัน policy deactivate
- 🖐️ หน้าเว็บ: SSR ทุก route 200 แล้ว รอ click-through - 🖐️ หน้าเว็บ: SSR ทุก route 200 แล้ว รอ click-through
### AC-16 — (v5) พิมพ์คำอธิบาย (commit message) ตอนสร้าง version ได้ทุก flow → เก็บกับ version นั้น + แสดงใน history ทุกหน้า · เว้นว่างได้ — ✅ (backend + service) + 🔶 (dialog ใน Word รอมือ)
- ✅ `VersionMessageTests` (backend): upload พร้อม message → ตรวจ row ใน Postgres + echo ใน history · blank/whitespace → null · trim · oversize 2049 → 400 ไม่มี side effect · ยาวพอดี 2048 ผ่าน · ต่อ version มี message ของตัวเอง · generate v1 พร้อม message · generate oversize → 400 ก่อนแตะ MinIO
- ✅ `versionFlow.test.ts` (addin): `saveAndVersion`/`startVersioning` แนบ `message` ใน multipart · whitespace ล้วน → ไม่แนบฟิลด์
- ✅ Web: `VersionListItem.message` + build ผ่าน (type-only ตาม convention ไม่มี component test)
- 🔶 dialog + textarea ใน task pane (Save & Version / Start Versioning) และการแสดงใน `VersionHistoryPanel` ต้องยืนยันด้วยมือบน Word จริง
## ชุด test ที่คุ้ม AC (สรุป) ## ชุด test ที่คุ้ม AC (สรุป)
| ชุด | จำนวน | ครอบ | | ชุด | จำนวน | ครอบ |
|---|---|---| |---|---|---|
| backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 42 | AC-3, 5, 6, 7 (backend), 8, 11–15 | | backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 52 | AC-3, 5, 6, 7 (backend), 8, 11–16 |
| addin unit (Jest) | 47 | AC-2, 4, 7 (client flow), 9, 10 | | addin unit (Jest) | 50 | AC-2, 4, 7 (client flow), 9, 10, 16 (ส่วน form) |
| web unit (Vitest) | 36 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering | | web unit (Vitest) | 36 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering |
## ปัญหาที่เจอระหว่างไล่ AC (และการแก้) ## ปัญหาที่เจอระหว่างไล่ AC (และการแก้)
@ -90,3 +96,4 @@
- [ ] ปล่อย session จริงจน token หมดอายุ → ยืนยัน silent refresh (AC-9) - [ ] ปล่อย session จริงจน token หมดอายุ → ยืนยัน silent refresh (AC-9)
- [ ] Click-through บน browser: สร้าง/แก้ template → สร้างเอกสาร → ดาวน์โหลด → เปิดใน Word ยืนยัน add-in เห็น v1 ทันที (AC-11, 12) + ดาวน์โหลด version ผ่าน presigned link บน Mac อีกครั้ง (AC-8) - [ ] Click-through บน browser: สร้าง/แก้ template → สร้างเอกสาร → ดาวน์โหลด → เปิดใน Word ยืนยัน add-in เห็น v1 ทันที (AC-11, 12) + ดาวน์โหลด version ผ่าน presigned link บน Mac อีกครั้ง (AC-8)
- [ ] Theme toggle จำค่าหลัง reload บน browser จริง (Phase 6 DoD ที่เหลือ) - [ ] Theme toggle จำค่าหลัง reload บน browser จริง (Phase 6 DoD ที่เหลือ)
- [ ] (v5) ใน Word จริง: textarea ใน dialog ของทั้ง Save & Version และ Start Versioning + ข้อความโชว์ใน `VersionHistoryPanel` (AC-16) · และบน browser: ฟอร์มสร้างเอกสารพร้อม message + แสดงใน history ทั้ง desktop/mobille

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8) # Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md) > แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: **Phase 0–8 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง > สถานะ: **Phase 0–9 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync) > วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
--- ---
@ -23,7 +23,7 @@
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที - หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template) - Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text - Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
- **FR-9 ถึง FR-12 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v4 (ครอบ FR-1–12 + AC-1–15: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate) ให้ถือ spec เป็น source of truth ของ requirements - **FR-9 ถึง FR-13 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v5 (ครอบ FR-1–13 + AC-1–16: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate, v5 เพิ่ม commit message ต่อ version) ให้ถือ spec เป็น source of truth ของ requirements
--- ---
@ -501,6 +501,28 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
--- ---
## Phase 9: Version message — FR-13 (เพิ่มทีหลัง, v5)
**เป้า:** ใส่ "คำอธิบาย version" แบบ git commit message ได้ทุก flow ที่สร้าง version (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า — แผนเต็มที่ [`docs/plans/version-message.md`](version-message.md)
- [x] Backend: `DocumentVersion.Message` (string?, varchar 2048) + `AppDbContext` config + migration `AddVersionMessage`
- [x] Backend: `UploadVersionRequest.Message` (multipart) + `GenerateDocumentRequest.Message` (JSON) + `VersionListItem.Message` + `NormalizeMessage` + BadRequest ตรวจความยาว 2048 ทั้ง upload/generate (กัน 500 + orphan MinIO object)
- [x] Add-in: `ConfirmVersionDialog` generalize (`title?/description?/confirmLabel?` + `onConfirm(message)`) + textarea ทางเลือก + เคลียร์ state ตาม `open` · Start Versioning ใช้ dialog ด้วย (ตัวใหม่ใน App.tsx)
- [x] Add-in: `versionFlow` (`saveAndVersion`/`startVersioning`/`uploadCurrentFile`) + `versioningService.uploadVersion` ส่ง `message` · `VersionRecord.message` + แสดงใน `VersionHistoryPanel`
- [x] Web: ฟอร์ม generate เพิ่ม textarea ทางเลือก + payload `message` · `VersionListItem.message` + แสดงใน history (desktop ใต้ v{N} truncate + tooltip, mobile เต็มบรรทัด)
- [x] Tests: `VersionMessageTests` (backend) + `versionFlow.test.ts` เพิ่ม 3 กรณี (แนบ/ว่างไม่แนบ/startVersioning)
หมายเหตุจากการทำจริง (2026-08-20):
- เพิ่ม param เป็น optional positional วางก่อน callback (callback-last) — test เดิมที่เรียก `saveAndVersion(id, 1)` / `startVersioning()` คอมไพล์ผ่านโดยไม่ต้องแก้
- `ConfirmVersionDialog` ถูก mount ค้างไว้นอก phase branch ของ App — เคลียร์ textarea ด้วย `useEffect` ตาม `open` ไม่ใช่ตอน unmount
- เลือกตรวจความยาว 2048 เป็น BadRequest เอง (ต่างจาก `Template.Description` ที่ปล่อยให้ DB reject) เพราะ Generate จะเกิด 500 **หลัง** MinIO upload แล้ว = orphan object ใน bucket
**DoD:** `dotnet test` + addin `npm test` + web `npm test`/`npm run build` เขียวทั้งชุด
> ✅ ตรวจผ่านจริง (2026-08-20): `dotnet test` **52/52** · addin `npm test` **50/50** · web `npm test` **36/36** + build ผ่าน
---
## 4. ตาราง mapping: Phase ↔ FR ↔ Acceptance Criteria ## 4. ตาราง mapping: Phase ↔ FR ↔ Acceptance Criteria
| Phase | FR ที่ implement | Acceptance criteria ที่ตรวจได้ | | Phase | FR ที่ implement | Acceptance criteria ที่ตรวจได้ |
@ -515,6 +537,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
| 6.4 (ทีหลัง, v4) | FR-12 | AC-15 | | 6.4 (ทีหลัง, v4) | FR-12 | AC-15 |
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) | | 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด | | 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
| 9 (ทีหลัง, v5) | FR-13 | AC-16 |
**AC เพิ่มเติม (นอก spec เดิม):** **AC เพิ่มเติม (นอก spec เดิม):**
- **AC-11:** upload template .docx ที่มี content controls → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้ - **AC-11:** upload template .docx ที่มี content controls → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
@ -522,6 +545,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- **AC-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ - **AC-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error - **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
- **AC-15:** (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12 - **AC-15:** (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12
- **AC-16:** (v5) พิมพ์คำอธิบายตอนสร้าง version ได้ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า · เว้นว่างได้ — รายละเอียดตาม FR-13
--- ---
@ -554,14 +578,14 @@ docker compose up -d
cd backend/WordVersioning.Api cd backend/WordVersioning.Api
dotnet run # รัน + swagger ที่ http://localhost:5xxx dotnet run # รัน + swagger ที่ http://localhost:5xxx
dotnet ef migrations add <Name> dotnet ef migrations add <Name>
cd .. && dotnet test # integration tests (42 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที) cd .. && dotnet test # integration tests (52 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)
# add-in (https://localhost:3000) # add-in (https://localhost:3000)
cd addin cd addin
npm install npm install
npm run build # webpack build npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest นี้ npm start # dev server — sideload ผ่าน manifest นี้
npm test # jest (47 ตัว) npm test # jest (50 ตัว)
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้ npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้
# web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in) # web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in)

View file

@ -0,0 +1,97 @@
# Version Message (commit message) — FR-13 / AC-16 / Spec v5
## Context
ผู้ใช้ต้องการให้ใส่ "ข้อความอธิบาย" ตอนสร้าง version ใหม่ได้ เหมือน git commit message — เก็บกับแต่ละ version และแสดงใน version history ทุกหน้า ขอบเขต (ยืนยันกับผู้ใช้แล้ว): **ทุก flow ที่สร้าง version** = add-in Save & Version + add-in Start Versioning (เพิ่ม dialog ใหม่) + ฟอร์ม generate บน web
**ข้อตกลง fixed:**
- Field ชื่อ `message` — nullable string, max 2048, normalize `IsNullOrWhiteSpace → null` + `.Trim()` (นโยบายเดียวกับ `Template.Description` เป๊ะ)
- Optional ทุกที่ ไม่บังคับกรอก
- **Non-goal: ไม่ฝังลง docx** (custom properties / Custom XML part) — backend คือ source of truth (กติกา #5) และเลี่ยง parse-compat กับไฟล์เก่า (`XmlVersionEntry` ไม่แตะ)
- ตามกติกา repo: **แก้ spec ก่อน** แล้ว sync plan
## ขั้นที่ 0 — บันทึกแผน + แก้เอกสาร requirement ก่อน
1. บันทึกแผนนี้ที่ `docs/plans/version-message.md` (ธรรมเนียมเดิม)
2. **[specs/spec-officejs.md](specs/spec-officejs.md)**: เพิ่ม changelog v5 บรรทัดบนสุด · `### FR-13: คำอธิบายต่อ version — commit message (เพิ่มเติม v5)` ต่อท้าย section 6 (หลัง FR-12) ตาม format เดิม — ระบุ field `message` + ทั้ง 2 endpoint + นโยบาย null/trim + แสดงผลทุก history + Start Versioning ได้ dialog · section 7 `DocumentVersion` POCO เพิ่ม `public string? Message { get; set; }` · section 10 เพิ่ม AC-16 (v5) · section 11 เพิ่ม task items `(v5)` ทั้ง 3 list
3. **[docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md)**: เพิ่ม `## Phase 9: Version message — FR-13 (เพิ่มทีหลัง, v5)` + checklist + แถวตาราง mapping `| 9 (v5) | FR-13 | AC-16 |`
4. **[docs/acceptance-review.md](docs/acceptance-review.md)**: 1–15 → 1–16 + section AC-16 (🔶 ส่วน dialog ใน Word ต้องยืนยันมือ)
## ขั้นที่ 1 — Backend (`backend/WordVersioning.Api`)
1. [Models/DocumentVersion.cs](backend/WordVersioning.Api/Models/DocumentVersion.cs): `public string? Message { get; set; }` (+ doc comment FR-13)
2. [Data/AppDbContext.cs](backend/WordVersioning.Api/Data/AppDbContext.cs): ใน block DocumentVersion เพิ่ม `entity.Property(x => x.Message).HasMaxLength(2048);` (มิเรอร์ Template.Description)
3. Migration: `dotnet ef migrations add AddVersionMessage` → expect `AddColumn<string>("Message", "DocumentVersions", nullable, maxLength 2048)` (tests รัน MigrateAsync อยู่แล้ว = migration ถูก exercise)
4. [Models/VersionDtos.cs](backend/WordVersioning.Api/Models/VersionDtos.cs): `UploadVersionRequest.Message` (string? หลัง Fields) · `GenerateDocumentRequest.Message` (string? หลัง Values) · `VersionListItem` เพิ่ม `string? Message` ท้าย record
5. [Controllers/DocumentsController.cs](backend/WordVersioning.Api/Controllers/DocumentsController.cs):
- เพิ่ม `MaxVersionMessageLength = 2048` + `NormalizeMessage()` helper (static private — มี pattern `ParseSnapshot`/`IsUniqueViolation` อยู่แล้ว)
- **ตัดสินใจ: ใส่ BadRequest check ความยาว** (ต่างจาก Template.Description ที่ไม่มี) เพราะ: ถ้าไม่เช็ค = 500 จาก DB และใน Generate จะเกิด 500 **หลัง** MinIO upload แล้ว = orphan object ใน bucket โดยเปล่า + UploadVersion เป็น action ที่ validate เองด้วยข้อความไทยอยู่แล้ว
- UploadVersion: normalize + check หลัง validation ของ fields → `Message = message` ใน `new DocumentVersion` ใน retry loop (ทุกรอบได้ค่าเดียวกัน)
- Generate: normalize + check **ก่อน** ทำ MinIO/template IO → `Message = message` ใน v1 initializer
- GetVersions: เพิ่ม `v.Message` ใน projection + `, r.Message` ตอน construct `VersionListItem`
- Rollback path ไม่ต้องแตะเพิ่ม (MinIO fail → Remove ทั้ง row อยู่แล้ว / Generate detach ก่อน SaveChanges)
## ขั้นที่ 2 — Add-in (`addin`) — Fluent UI v9
1. [ConfirmVersionDialog.tsx](addin/src/taskpane/components/ConfirmVersionDialog.tsx) — generalize ด้วย optional props (ไม่สร้าง component ใหม่): `title?`, `description?`, `confirmLabel?` + **`onConfirm: (message: string) => void`**; เพิ่ม `Field`+`Textarea` (label "คำอธิบาย version (ไม่บังคับ)", placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง", maxLength 2048, rows 3, resize vertical, disabled={busy}); local state `message` + **`useEffect` เคลียร์เมื่อ `!open`** (FC ถูก mount ค้างไว้ — ห้ามพึ่ง remount)
2. [versionFlow.ts](addin/src/services/versionFlow.ts) — optional positional param วางก่อน callback (callback-last): `uploadCurrentFile(documentId, clientVersionId, fields, message?)` · `startVersioning(message?, onProgress?)` · `saveAndVersion(documentId, latestVersionNumber, message?, onProgress?)` — test เดิม 5 ตัวเรียก 2 args / 0 args คอมไพล์ผ่านไม่ต้องแก้
3. [versioningService.ts](addin/src/services/versioningService.ts) `uploadVersion` — เพิ่ม `message?` ท้าย signature + `const trimmed = message?.trim(); if (trimmed) form.append("message", trimmed)` (มิเรอร์ guard ของ fields)
4. [versionRecord.ts](addin/src/models/versionRecord.ts) — `VersionRecord` เพิ่ม `message: string | null`
5. [VersionHistoryPanel.tsx](addin/src/taskpane/components/VersionHistoryPanel.tsx) — แสดง `version.message` ใต้บรรทัด "โดย …" (แบบเต็ม, `pre-wrap`, muted เล็ก) — เพิ่ม style `messageRow`
6. [App.tsx](addin/src/taskpane/components/App.tsx) — `handleSaveAndVersion(message)` / `handleStartVersioning(message)` ส่งต่อ; state `startConfirmOpen`; ปุ่ม Start Versioning เปลี่ยนเป็นเปิด dialog; render `ConfirmVersionDialog` ตัวที่สอง (title "เริ่มติดตามเอกสารนี้?", confirmLabel "เริ่มติดตาม (version 1)")
## ขั้นที่ 3 — Web (`web`)
1. [documents/create/[templateId].vue](web/app/pages/documents/create/[templateId].vue) — `const message = ref('')`; textarea ทางเลือก (`label`/`input` class, rows 2, maxlength 2048) หลัง field loop; payload เพิ่ม `message: message.value.trim() || null`; `resetForm` เคลียร์; **re-generate ไม่ prefill message** (เอกสารใหม่ได้ข้อความใหม่ — ใส่ comment กำกับ)
2. [types/api.ts](web/app/types/api.ts) — `VersionListItem` เพิ่ม `message: string | null`
3. [documents/[documentId].vue](web/app/pages/documents/[documentId].vue) — **ไม่เพิ่มคอลัมน์ใหม่** (colspan="4" ใน expanded row + คอลัมน์ action จะบีบ): desktop แสดงใต้ `v{N}` เป็น `truncate text-xs text-muted` + `:title` tooltip; mobile card แสดงเต็ม `break-words`
## ขั้นที่ 4 — Tests
**Backend — ไฟล์ใหม่ `VersionMessageTests.cs`** (ตาม pattern `UploadVersionTests` — `VersionForm`, `fixture.CreateDb()`, `AsNoTracking`):
1. `Upload_WithMessage_PersistsRow_AndEchoesInHistory`
2. `[Theory] Upload_BlankOrMissingMessage_StoredAsNull` (null / "" / " ")
3. `Upload_TrimsSurroundingWhitespace`
4. `Upload_OversizeMessage_Returns400_NoSideEffect` (2049 ตัว, ไม่มี row หลงเหลือ)
5. `Upload_MessageExactly2048_Succeeds` (boundary)
6. `SecondUpload_KeepsPerVersionMessages` (v1=A, v2=B → history [B,A])
7. `Generate_WithMessage_CreatesV1WithMessage`
8. `Generate_OversizeMessage_Returns400` (เช็คว่า 400 เกิดก่อน MinIO)
แก้ helper: `TestDtos.cs` — `VersionItem` เพิ่ม `string? Message`, `VersionForm(..., string? message = null)`; `TestFlow.GenerateDocumentAsync(..., string? message = null)`
**Add-in — `versionFlow.test.ts`**: เพิ่ม 3 test — `form.get("message")` หลัง `saveAndVersion(id, 1, "…")`, whitespace → null, `startVersioning("…")` แนบ v1 (test เดิมไม่ต้องแก้)
**Web**: type-only — ไม่มี component test ตาม convention (เช็คด้วย build)
## Verification
```
# ลำดับ: docs → backend → add-in → web
cd backend/WordVersioning.Api && dotnet ef migrations add AddVersionMessage
cd backend && dotnet test --filter VersionMessageTests # ต้องมี Docker (Testcontainers)
cd backend && dotnet test # ทั้งชุด
cd addin && npm run build && npm test
cd web && npm run build && npm test
```
Manual E2E (หลัง `docker compose up -d` + backend + `npm run dev`/`npm start`):
1. Web generate พร้อม message → หน้า detail แสดงใต้ v1 (desktop + mobile card); ไม่กรอก → ไม่แสดง
2. Re-generate จากเอกสารที่มี message → textarea ว่าง
3. Add-in: untracked → Start Versioning เปิด dialog → พิมพ์ message → v1 + panel แสดง
4. Add-in: Save & Version พร้อม message → history refresh แสดง
5. เปิด dialog → พิมพ์ → ยกเลิก → เปิดใหม่ → textarea ต้องว่าง
6. Word sideload caveat: ห้าม `cp` ทับ manifest ใน wef (symlink), kill dev server เก่าก่อน `npm start`
## Risks / gotchas
- **Param เพิ่มท้าย + callback-last** = test เดิมคอมไพล์ผ่าน (verify แล้วว่าเรียก 2/0 args)
- **เคลียร์ dialog state ด้วย useEffect ตาม `open`** — อย่าพึ่ง unmount
- Postgres varchar นับเป็น characters (ไทย 3 bytes/char ไม่มีปัญหา); JS maxLength กับ C# `.Length` นับ UTF-16 code units เหมือนกัน
- `GenerateDocumentRequest.Message` เป็น plain string — JSON ส่ง type ผิดได้ 400 อัตโนมัติจาก [ApiController] (body อังกฤษ — ยอมรับได้)
- AC-16 ส่วน dialog/textarea ใน Word webview ต้องยืนยันมือ (🔶 ใน acceptance-review)
- อย่าลืมอัปเดตตัวเลข test count ใน docs (47/42/36) หลังรันจริง
## สถานะ: ✅ implement เสร็จ (2026-08-20)
ผลรันจริงทั้งชุด: backend `dotnet test` **52/52** · addin `npm test` **50/50** + build + lint + manifest validate ผ่าน · web `npm test` **36/36** + build ผ่าน — รายละเอียด checkbox Phase 9 + หมายเหตุจากการทำจริงอยู่ที่ [officejs-implementation-plan.md](officejs-implementation-plan.md#phase-9-version-message--fr-13-เพิ่มทีหลัง-v5) · ผล AC-16 ที่ [acceptance-review.md](../acceptance-review.md) · เหลือยืนยันด้วยมือเฉพาะ dialog/textarea ใน Word จริง

View file

@ -186,3 +186,9 @@ Desktop เป็นตารางปกติ (`hidden md:block table-card`)
- Dark ครีม→เทาอุ่น: saturation เกิน ~15% จะเลี่ยน — ค่าที่เลือก desaturate แล้ว; shadow มองไม่เห็นบนพื้นดำ (border token เป็นตัวแบ่ง elevation ใน dark) - Dark ครีม→เทาอุ่น: saturation เกิน ~15% จะเลี่ยน — ค่าที่เลือก desaturate แล้ว; shadow มองไม่เห็นบนพื้นดำ (border token เป็นตัวแบ่ง elevation ใน dark)
- ตารางครีมที่ text-sm อาจแน่น — fallback บรรทัดเดียว: `table-card` → `bg-surface` - ตารางครีมที่ text-sm อาจแน่น — fallback บรรทัดเดียว: `table-card` → `bg-surface`
- ลำดับสำคัญ: tokens/config → component classes → .vue edits - ลำดับสำคัญ: tokens/config → component classes → .vue edits
## หมายเหตุจากการทำจริง (2026-08-20)
- ✅ **implement ครบทั้ง 12 ไฟล์ + verification ผ่านทั้งหมด** — web `npm test` 36/36 เขียวทั้งก่อน/หลัง · `npm run build` ผ่าน · smoke test production server ยืนยัน fonts link, `lang="th"`, sticky header + token classes, FOUC script ครบ · audit grep palette เดิม (`slate/sky/red/emerald/amber-*`) = **0 เหลือ** · `dark:` variants (เดิม 209 จุด) = **0 เหลือ**
- 🐛 **บั๊กแฝงที่เจอระหว่างทำ (สำคัญ):** `@nuxtjs/tailwindcss` resolve default cssPath แบบ relative ต่อ rootDir → บน Nuxt 4 (srcDir = `app/`) หา `app/assets/css/tailwind.css` ไม่เจอและ **fallback ไปไฟล์ default ของ tailwindcss package เงียบๆ** (build ผ่าน แต่ token/component classes หายทั้งหมด) — ก่อนหน้านี้ไม่มีใครเจอเพราะไฟล์เดิมมีเนื้อหา 3 directives เหมือน fallback เป๊ะ · แก้ด้วย `tailwindcss: { cssPath: '~/assets/css/tailwind.css' }` ใน nuxt.config.ts (บันทึกเป็นกติกา #14 ใน CLAUDE.md แล้ว)
- smoke test เครื่องมือ: `PORT=5174 node .output/server/index.mjs` + curl ตรวจ HTML — ใช้ซ้ำได้เมื่อแก้ shell/global

View file

@ -1,5 +1,6 @@
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) # Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า
> ฉบับแก้ไข v4 (2026-08-20): เพิ่ม FR-12 — รายการเอกสารต่อ template + document detail + re-generate (Web Portal) > ฉบับแก้ไข v4 (2026-08-20): เพิ่ม FR-12 — รายการเอกสารต่อ template + document detail + re-generate (Web Portal)
> ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link > ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md` > ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
@ -254,6 +255,17 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- หน้าเว็บ (Web Portal): หน้ารายการเอกสารของ template + หน้า detail ที่แสดงค่า fields ปัจจุบันและ version history (ขยายแต่ละ version เห็น snapshot ค่าตอนนั้น + ดาวน์โหลด) - หน้าเว็บ (Web Portal): หน้ารายการเอกสารของ template + หน้า detail ที่แสดงค่า fields ปัจจุบันและ version history (ขยายแต่ละ version เห็น snapshot ค่าตอนนั้น + ดาวน์โหลด)
- **Re-generate:** จากหน้า detail ของเอกสารที่สร้างจาก template → เข้าฟอร์มสร้างเอกสารโดย **prefill ค่าล่าสุดของทุก field** ให้แก้ไขก่อน → สร้างเป็น **เอกสารใหม่ (v1 ใหม่ + documentId ใหม่)** ผ่าน endpoint generate เดิม — เอกสารต้นทางและ version history ของมัน**ไม่ถูกแตะ** (ไม่เกิด version ใหม่) — template ที่ deactivate จะ re-generate ไม่ได้ตามกติกาของ FR-10 - **Re-generate:** จากหน้า detail ของเอกสารที่สร้างจาก template → เข้าฟอร์มสร้างเอกสารโดย **prefill ค่าล่าสุดของทุก field** ให้แก้ไขก่อน → สร้างเป็น **เอกสารใหม่ (v1 ใหม่ + documentId ใหม่)** ผ่าน endpoint generate เดิม — เอกสารต้นทางและ version history ของมัน**ไม่ถูกแตะ** (ไม่เกิด version ใหม่) — template ที่ deactivate จะ re-generate ไม่ได้ตามกติกาของ FR-10
### FR-13: คำอธิบายต่อ version — commit message (เพิ่มเติม v5)
- Field ชื่อ `message` — string ทางเลือก (null ได้) ยาวไม่เกิน **2048 อักขระ** เก็บที่ `DocumentVersion.Message` — ผู้ใช้พิมพ์อธิบายการเปลี่ยนแปลงของ version นั้นแบบเดียวกับ git commit message
- รับจาก**ทุก flow ที่สร้าง version**:
- `POST /api/documents/{documentId}/versions` — multipart form field `message` (เพิ่มจาก `file`/`clientVersionId`/`fields` เดิม)
- `POST /api/documents/generate` — JSON property `message` (เพิ่มจาก `templateId`/`values` เดิม)
- ค่าว่าง/whitespace ล้วน → เก็บ `null` (trim ก่อนเก็บ — นโยบายเดียวกับ `Template.Description`) · เกิน 2048 อักขระ → 400
- แสดงใน version history ทุกที่: ตาราง + การ์ดมือถือของ Web Portal (FR-12) และ `VersionHistoryPanel` ของ add-in (FR-4)
- Add-in: dialog ยืนยันมี textarea ทางเลือก — ทั้ง **Save & Version** (FR-2) และ **Start Versioning** (FR-1 ซึ่ง v5 นี้เพิ่ม dialog ยืนยันให้ flow เริ่ม track ก่อนอัปโหลด v1)
- Web Portal: ฟอร์มสร้างเอกสาร (FR-10) มี textarea ทางเลือก — re-generate ตาม FR-12 **ไม่ prefill ข้อความ** (เป็นเอกสารใหม่ ได้ข้อความใหม่)
- **ไม่ฝังลงไฟล์ .docx** (custom properties หรือ custom XML part) — backend คือ source of truth ของ version history ตามกติกา และเลี่ยงปัญหา backward-compat กับไฟล์เก่า
--- ---
## 7. Data Model (Backend) ## 7. Data Model (Backend)
@ -278,6 +290,7 @@ public class DocumentVersion
public string CreatedByUserId { get; set; } public string CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public string? FieldsSnapshotJson { get; set; } // snapshot ค่า fields ของ version นี้ (FR-11) public string? FieldsSnapshotJson { get; set; } // snapshot ค่า fields ของ version นี้ (FR-11)
public string? Message { get; set; } // คำอธิบาย version จากผู้ใช้ แบบ commit message (FR-13) — ยาวสูงสุด 2048
} }
public class Template // เพิ่มเติม v2 (FR-9) public class Template // เพิ่มเติม v2 (FR-9)
@ -374,6 +387,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ 13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error 14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
15. (v4) เข้าหน้ารายการเอกสารของ template บนเว็บ → เห็นเอกสารที่สร้างจาก template นั้นครบ พร้อมจำนวน version และเวลาล่าสุด · เข้าหน้า detail → เห็นค่าทุก field ปัจจุบัน + version history (ขยายเห็นค่าต่อ version, ดาวน์โหลดได้) · กด re-generate แล้วสร้างเอกสารใหม่เป็น v1 โดย**เอกสารต้นทางไม่มี version ใหม่** 15. (v4) เข้าหน้ารายการเอกสารของ template บนเว็บ → เห็นเอกสารที่สร้างจาก template นั้นครบ พร้อมจำนวน version และเวลาล่าสุด · เข้าหน้า detail → เห็นค่าทุก field ปัจจุบัน + version history (ขยายเห็นค่าต่อ version, ดาวน์โหลดได้) · กด re-generate แล้วสร้างเอกสารใหม่เป็น v1 โดย**เอกสารต้นทางไม่มี version ใหม่**
16. (v5) พิมพ์คำอธิบายตอนสร้าง version ได้จากทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า (web ตาราง + การ์ดมือถือ, add-in panel) · เว้นว่างได้ (ไม่ส่ง/ช่องว่างล้วน = ไม่มีข้อความ ไม่ error)
--- ---
@ -387,6 +401,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
5. เขียน `auth-callback.html` (หน้าเล็กๆ รับ redirect จาก Keycloak แล้ว `messageParent` กลับ) 5. เขียน `auth-callback.html` (หน้าเล็กๆ รับ redirect จาก Keycloak แล้ว `messageParent` กลับ)
6. เขียน manifest.xml ให้ครบ requirement sets ที่ใช้ 6. เขียน manifest.xml ให้ครบ requirement sets ที่ใช้
7. เขียน unit test สำหรับ service layer (mock `Word.run`, `fetch`, และ mock Keycloak token response) 7. เขียน unit test สำหรับ service layer (mock `Word.run`, `fetch`, และ mock Keycloak token response)
8. (v5) `ConfirmVersionDialog` เพิ่ม textarea ทางเลือก (commit message) + ให้ Start Versioning ใช้ dialog ด้วย + ส่ง `message` ผ่าน `versionFlow`/`versioningService` + แสดงใน `VersionHistoryPanel` (FR-13)
### Backend ### Backend
1. Scaffold ASP.NET Core 8 Web API project 1. Scaffold ASP.NET Core 8 Web API project
@ -402,6 +417,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
11. (v2) Implement `DocxGenerationService` + `POST /api/documents/generate` (FR-10) — แทนที่ SDT ที่ run level + stamp custom properties + สร้าง Document + v1 + field values ใน flow เดียว 11. (v2) Implement `DocxGenerationService` + `POST /api/documents/generate` (FR-10) — แทนที่ SDT ที่ run level + stamp custom properties + สร้าง Document + v1 + field values ใน flow เดียว
12. (v2) ขยาย upload endpoint ให้รับ `fields` แล้ว sync `DocumentFieldValue` + snapshot ต่อ version ตาม missing-field policy (FR-11) 12. (v2) ขยาย upload endpoint ให้รับ `fields` แล้ว sync `DocumentFieldValue` + snapshot ต่อ version ตาม missing-field policy (FR-11)
13. (v2) เขียน integration test ครอบ template detect / generate / field sync (รวมกรณี field หาย → ค่าเดิม + mark missing) 13. (v2) เขียน integration test ครอบ template detect / generate / field sync (รวมกรณี field หาย → ค่าเดิม + mark missing)
14. (v5) เพิ่ม `DocumentVersion.Message` + migration `AddVersionMessage` + รับ/คืน `message` ทั้ง upload/generate/history + ตรวจความยาว 2048 (FR-13)
### Web Portal (เพิ่มเติม v2) ### Web Portal (เพิ่มเติม v2)
1. Scaffold Nuxt.js + TailwindCSS — dev port 5173, `darkMode: 'class'`, `useTheme` (light/dark toggle + persist + system default) 1. Scaffold Nuxt.js + TailwindCSS — dev port 5173, `darkMode: 'class'`, `useTheme` (light/dark toggle + persist + system default)
@ -410,3 +426,4 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์ 4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
5. เขียน unit test ของ form validation + useTheme (Vitest) 5. เขียน unit test ของ form validation + useTheme (Vitest)
6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน 6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน
7. (v5) ฟอร์มสร้างเอกสารเพิ่ม textarea ทางเลือก (commit message) + แสดง `message` ใน version history (FR-13)

View file

@ -1,75 +1,44 @@
# Nuxt Minimal Starter # Web Portal — e-Court Documents Versioning
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. หน้าเว็บสำหรับจัดการ template + สร้างเอกสารจาก template (ได้ .docx ที่เป็น version 1 อัตโนมัติ) + ดูรายการเอกสาร/detail + re-generate — เป็นส่วนหนึ่งของ monorepo หลัก (ดู [../README.md](../README.md) สำหรับภาพรวมทั้งระบบ)
## Setup ## สั่งรัน
Make sure to install dependencies:
```bash ```bash
# npm
npm install npm install
npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชนกับ add-in dev server)
# pnpm npm test # Vitest — 36 ตัว
pnpm install npm run build # production build (.output/)
# yarn
yarn install
# bun
bun install
``` ```
## Development Server ต้องรัน infra + backend ก่อน (docker compose + `dotnet run` ที่ backend) — login ผ่าน Keycloak ด้วย test user `testuser` / `Test1234!`
Start the development server on `http://localhost:3000`: ## โครงสร้าง
```bash ```
# npm app/
npm run dev app.vue # shell: sticky header + navbar + skip link
assets/css/tailwind.css # design tokens (CSS variables) + @layer components (.btn/.input/.card/…)
# pnpm composables/ # useAuth (PKCE), useApi (Bearer + refresh), useTheme (light/dark)
pnpm dev pages/ # ทุกหน้า: หน้าหลัก / templates (list/new/edit/documents) / documents (create/detail) / auth/callback
types/api.ts # DTO mirror ของ backend
# yarn utils/ # oidc, fieldValidation, snapshot — pure functions ไว้ unit test
yarn dev test/ # Vitest (happy-dom) — fieldValidation / useTheme / snapshot
tailwind.config.ts # สี/fontSize/radius/shadow expose จาก CSS variables (rgb(var(--c-x) / <alpha-value>))
# bun
bun run dev
``` ```
## Production ## Design system + responsive
Build the application for production: - **สี/ฟอนต์/radius/shadow เป็น token ทั้งหมด** — CSS variables (`:root` + `.dark`) ใน `app/assets/css/tailwind.css`, derive จาก scheme ของ admincourt.go.th (primary `#0000EE`, elevated ครีม `#FBF7EC`, ฟอนต์ Sukhumvit Set → Sarabun ผ่าน Google Fonts) · dark mode พลิกที่ตัวแปร ไม่ใช้ `dark:` utility คู่
- **Component classes กลาง** — `.btn*` / `.input` / `.label` / `.card` / `.table-card` / `.badge*` / `.alert*` / `.spinner` ใน `@layer components`
- **Responsive ทุกหน้า** — ตารางทุกตัว dual-render (desktop ตาราง / ต่ำกว่า `md` เป็น card list), page header stack บนมือถือ, ฟอร์ม action row `flex-col-reverse → sm:flex-row`
```bash ⚠️ **กติกาสำคัญ:**
# npm - แก้ `tailwind.config.ts` แล้วต้อง **restart dev server** ทุกครั้ง
npm run build - CSS variables ต้องเป็น **RGB triplet เว้นวรรค** (`0 0 238`) — ใส่ hex จะ break `/opacity` utilities เงียบๆ
- ต้องระบุ `tailwindcss.cssPath: '~/assets/css/tailwind.css'` ใน `nuxt.config.ts` (default resolve ผิดบน Nuxt 4 → fallback ไปไฟล์ default ของ tailwindcss package แบบเงียบ — ดูกติกา #14 ของ CLAUDE.md หลัก)
- Token เก็บใน memory เท่านั้น — persist ได้เฉพาะ theme (localStorage) + refresh token/PKCE verifier (sessionStorage อายุสั้น)
# pnpm ## Config
pnpm build
# yarn ค่า default ชี้ dev ในพื้นที่ override ผ่าน env ได้ทั้งหมด (ดู [.env.example](.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID`
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View file

@ -185,6 +185,7 @@ onMounted(load)
<p class="font-medium">v{{ v.versionNumber }}</p> <p class="font-medium">v{{ v.versionNumber }}</p>
<p class="mt-1 text-xs text-muted">{{ formatDateTime(v.createdAt) }}</p> <p class="mt-1 text-xs text-muted">{{ formatDateTime(v.createdAt) }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</p> <p class="mt-0.5 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</p>
<p v-if="v.message" class="mt-1 text-sm break-words">{{ v.message }}</p>
</div> </div>
<div class="flex shrink-0 flex-col items-end gap-2"> <div class="flex shrink-0 flex-col items-end gap-2">
<button <button
@ -241,7 +242,16 @@ onMounted(load)
class="cursor-pointer hover:bg-surface-muted/60" class="cursor-pointer hover:bg-surface-muted/60"
@click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId" @click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId"
> >
<td class="px-4 py-3 font-medium">v{{ v.versionNumber }}</td> <td class="px-4 py-3 font-medium">
v{{ v.versionNumber }}
<p
v-if="v.message"
class="max-w-md truncate text-xs font-normal text-muted"
:title="v.message"
>
{{ v.message }}
</p>
</td>
<td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td> <td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</td> <td class="px-4 py-3 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</td>
<td class="px-4 py-3 text-right whitespace-nowrap"> <td class="px-4 py-3 text-right whitespace-nowrap">

View file

@ -10,6 +10,8 @@ const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null) const detail = ref<TemplateDetail | null>(null)
const values = ref<Record<string, string>>({}) const values = ref<Record<string, string>>({})
const issues = ref<Record<string, string>>({}) const issues = ref<Record<string, string>>({})
/** คำอธิบาย version แบบ commit message (FR-13) — ทางเลือก · re-generate ไม่ prefill เพราะเป็นเอกสารใหม่ */
const message = ref('')
/** เอกสารต้นทางของ re-generate (จาก ?from=) — ใช้ทั้ง prefill และ banner บอกบริบท */ /** เอกสารต้นทางของ re-generate (จาก ?from=) — ใช้ทั้ง prefill และ banner บอกบริบท */
const fromDoc = ref<{ id: string; fileName: string } | null>(null) const fromDoc = ref<{ id: string; fileName: string } | null>(null)
@ -82,7 +84,7 @@ async function submit() {
result.value = await apiJson<GenerateDocumentResponse>('/api/documents/generate', { result.value = await apiJson<GenerateDocumentResponse>('/api/documents/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId: templateId.value, values: values.value }), body: JSON.stringify({ templateId: templateId.value, values: values.value, message: message.value.trim() || null }),
}) })
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0 })
} catch (err) { } catch (err) {
@ -113,6 +115,7 @@ function resetForm() {
downloadError.value = null downloadError.value = null
issues.value = {} issues.value = {}
values.value = { ...blankValues(), ...prefill.value } values.value = { ...blankValues(), ...prefill.value }
message.value = ''
} }
onMounted(load) onMounted(load)
@ -220,6 +223,18 @@ onMounted(load)
<p v-if="issues[f.key]" class="mt-1 text-xs text-error-strong">{{ issues[f.key] }}</p> <p v-if="issues[f.key]" class="mt-1 text-xs text-error-strong">{{ issues[f.key] }}</p>
</div> </div>
<div>
<label for="doc-message" class="label">คำอธิบาย version (ไม่บังคับ)</label>
<textarea
id="doc-message"
v-model="message"
class="input"
rows="2"
maxlength="2048"
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
/>
</div>
<div v-if="sortedFields.length === 0" class="empty-state"> <div v-if="sortedFields.length === 0" class="empty-state">
template นี้ไม่มี field definitions — template นี้ไม่มี field definitions —
<NuxtLink :to="`/templates/${detail.id}/edit`" class="link">ตรวจสอบที่หน้าจัดการ template</NuxtLink> <NuxtLink :to="`/templates/${detail.id}/edit`" class="link">ตรวจสอบที่หน้าจัดการ template</NuxtLink>

View file

@ -98,4 +98,6 @@ export interface VersionListItem {
createdByUserId: string createdByUserId: string
contentHash: string contentHash: string
fields: unknown fields: unknown
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
message: string | null
} }