diff --git a/CLAUDE.md b/CLAUDE.md index 301e6dc..fc8cb60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) -**สถานะปัจจุบัน: 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) -- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC -- `docs/acceptance-review.md` — ผลตรวจ AC 1–15 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ +- `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 แบ่ง 10 phases (0–9) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC +- `docs/acceptance-review.md` — ผลตรวจ AC 1–16 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ เมื่อ 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` 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 รูปเดียว +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 หลักที่ต้องเข้าใจ **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 @@ -68,7 +71,7 @@ dotnet ef migrations add # backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที) cd backend -dotnet test # slnx ครอบทั้ง API + tests (42 tests) +dotnet test # slnx ครอบทั้ง API + tests (52 tests) dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว # add-in (https://localhost:3000) diff --git a/README.md b/README.md index 055ac38..9995360 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v1 ของเอกสารใหม่ **เอกสารต้นทางและ version history ไม่ถูกแตะ** - 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history - 💾 **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 กับเนื้อหาจริงเสมอ - ⬇️ **ดาวน์โหลด 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 เต็มชุด | | 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) | -| 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) | +| — | 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) -- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done -- [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 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 แบ่ง 10 phases (0–9) พร้อม definition of done +- [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) @@ -108,15 +113,15 @@ dotnet run # รันทั้ง http://localhost:5075 แ **API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer `): ``` -# Documents / versions (FR-5–7, 11, 12) -POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync +# Documents / versions (FR-5–7, 11, 12, 13) +POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync + commit message 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-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO) -# Generate จาก template (FR-10) -POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata +# Generate จาก template (FR-10, 13) +POST /api/documents/generate # { templateId, values, message? } → เอกสาร v1 พร้อม metadata # Templates (FR-9, 12) GET /api/templates # รายการ (?includeInactive=true สำหรับ admin) @@ -128,7 +133,7 @@ PUT /api/templates/{id}/file # แทนท 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 - 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) จะรู้จักทันที @@ -180,12 +185,12 @@ curl -X PUT http://localhost:5075/api/templates/$TPL_ID \ { "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 curl -X POST http://localhost:5075/api/documents/generate \ -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" } 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) -**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 curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ -H "Authorization: Bearer $TOKEN" \ -F "file=@ไฟล์แก้แล้ว.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \ -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) @@ -212,7 +218,7 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ cd addin npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost) 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 validate # ตรวจ 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) | | `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ | | `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in | -| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) | -| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec | +| `versioningService.ts` | uploadVersion (แนบ `fields` + `message`), getVersions, downloadVersion + getDownloadLink (presigned) | +| `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) @@ -243,10 +249,10 @@ npm run build **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** -- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection +- **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 พร้อมช่อง **คำอธิบาย 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) -- **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 พร้อมข้อความ **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/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) | | `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail | -| `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) → สร้างเป็น v1 + ดาวน์โหลด | -| `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history (กดขยายแถวเห็นค่า fields ตอนนั้น + ดาวน์โหลดต่อ version) + ปุ่ม **⟳ re-generate** | +| `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v1 + ดาวน์โหลด | +| `/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 ให้เรียบร้อย → กลับมาหน้าหลักเพื่อสร้างเอกสาร -**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` ## Tests -ชุด test ทั้งหมดของระบบ — รวม **125 ตัว** ครอบทุก flow ตาม acceptance criteria: +ชุด test ทั้งหมดของระบบ — รวม **138 ตัว** ครอบทุก flow ตาม acceptance criteria: ```bash -# 1) backend integration (42 ตัว) — ต้องมี Docker รันอยู่ +# 1) backend integration (52 ตัว) — ต้องมี Docker รันอยู่ cd backend 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 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) - **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) +- **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 **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) diff --git a/addin/src/models/versionRecord.ts b/addin/src/models/versionRecord.ts index 4085ca6..b6f6c82 100644 --- a/addin/src/models/versionRecord.ts +++ b/addin/src/models/versionRecord.ts @@ -32,6 +32,8 @@ export interface VersionRecord { /** SHA256 hex */ contentHash: string; fields: FieldsSnapshot | null; + /** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */ + message: string | null; } /** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */ diff --git a/addin/src/services/versionFlow.test.ts b/addin/src/services/versionFlow.test.ts index 9f49f24..34a22bc 100644 --- a/addin/src/services/versionFlow.test.ts +++ b/addin/src/services/versionFlow.test.ts @@ -117,6 +117,20 @@ describe("startVersioning (FR-1 / AC-5)", () => { const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); 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)", () => { @@ -213,4 +227,42 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => { 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(); + }); }); diff --git a/addin/src/services/versionFlow.ts b/addin/src/services/versionFlow.ts index b7faf78..5bce1a7 100644 --- a/addin/src/services/versionFlow.ts +++ b/addin/src/services/versionFlow.ts @@ -70,15 +70,18 @@ async function saveDocument(): Promise { async function uploadCurrentFile( documentId: string, clientVersionId: string, - fields: Record + fields: Record, + message?: string ): Promise<{ versionId: string; versionNumber: number }> { 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( - onProgress?: (message: string) => void + message?: string, + onProgress?: (progress: string) => void ): Promise { const documentId = generateGuid(); const clientVersionId = generateGuid(); @@ -106,15 +109,17 @@ export async function startVersioning( // 4) upload version แรก onProgress?.("กำลังอัปโหลด version 1…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields); + const result = await uploadCurrentFile(documentId, clientVersionId, fields, message); 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( documentId: string, latestVersionNumber: number, - onProgress?: (message: string) => void + message?: string, + onProgress?: (progress: string) => void ): Promise { const clientVersionId = generateGuid(); const timestamp = new Date().toISOString(); @@ -142,7 +147,7 @@ export async function saveAndVersion( // 4) upload onProgress?.("กำลังอัปโหลด version ใหม่…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields); + const result = await uploadCurrentFile(documentId, clientVersionId, fields, message); // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) if (result.versionNumber !== predictedNumber) { diff --git a/addin/src/services/versioningService.ts b/addin/src/services/versioningService.ts index 16e3e4b..6e21dbd 100644 --- a/addin/src/services/versioningService.ts +++ b/addin/src/services/versioningService.ts @@ -8,13 +8,15 @@ import { generateGuid } from "./metadataService"; * `fields` = map จาก `readContentControlFields()` ตาม FR-11 */ -/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) */ +/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) + * + FR-13: แนบ commit message ทางเลือกของผู้ใช้ */ export async function uploadVersion( documentId: string, file: Blob, clientVersionId: string, fields?: Record, - fileName?: string + fileName?: string, + message?: string ): Promise { const form = new FormData(); form.append("file", file, fileName || `${documentId}.docx`); @@ -22,6 +24,10 @@ export async function uploadVersion( if (fields && Object.keys(fields).length > 0) { form.append("fields", JSON.stringify(fields)); } + const trimmedMessage = message?.trim(); + if (trimmedMessage) { + form.append("message", trimmedMessage); + } return apiSendExpectJson( `/api/documents/${documentId}/versions`, { method: "POST", body: form }, diff --git a/addin/src/taskpane/components/App.tsx b/addin/src/taskpane/components/App.tsx index 8403129..096f843 100644 --- a/addin/src/taskpane/components/App.tsx +++ b/addin/src/taskpane/components/App.tsx @@ -87,6 +87,7 @@ const App: React.FC = () => { const [saving, setSaving] = React.useState(false); const [progressMessage, setProgressMessage] = React.useState(null); const [confirmOpen, setConfirmOpen] = React.useState(false); + const [startConfirmOpen, setStartConfirmOpen] = React.useState(false); const [flowError, setFlowError] = React.useState(null); const [flowSuccess, setFlowSuccess] = React.useState(null); @@ -209,13 +210,13 @@ const App: React.FC = () => { setPhase("signed-out"); }; - /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */ - const handleStartVersioning = async () => { + /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) */ + const handleStartVersioning = async (message: string) => { setStarting(true); setFlowError(null); setFlowSuccess(null); try { - const result = await startVersioning(setProgressMessage); + const result = await startVersioning(message, setProgressMessage); setDocumentId(result.documentId); setPhase("tracking"); setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); @@ -230,8 +231,9 @@ const App: React.FC = () => { } }; - /** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */ - const handleSaveAndVersion = async () => { + /** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload + * message คือ commit message จาก dialog (FR-13) */ + const handleSaveAndVersion = async (message: string) => { if (!documentId) { return; } @@ -240,7 +242,7 @@ const App: React.FC = () => { setFlowSuccess(null); try { 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} เรียบร้อย`); await refreshHistory(documentId); } catch (error) { @@ -349,7 +351,7 @@ const App: React.FC = () => { appearance="primary" icon={} disabled={starting} - onClick={() => void handleStartVersioning()} + onClick={() => setStartConfirmOpen(true)} > {starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"} @@ -422,9 +424,20 @@ const App: React.FC = () => { void handleSaveAndVersion()} + onConfirm={(message) => void handleSaveAndVersion(message)} onDismiss={() => setConfirmOpen(false)} /> + + {/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */} + void handleStartVersioning(message)} + onDismiss={() => setStartConfirmOpen(false)} + /> ); }; diff --git a/addin/src/taskpane/components/ConfirmVersionDialog.tsx b/addin/src/taskpane/components/ConfirmVersionDialog.tsx index 79c8cbf..fbffb49 100644 --- a/addin/src/taskpane/components/ConfirmVersionDialog.tsx +++ b/addin/src/taskpane/components/ConfirmVersionDialog.tsx @@ -7,31 +7,68 @@ import { DialogContent, DialogSurface, DialogTitle, + Field, Spinner, + Textarea, } from "@fluentui/react-components"; interface ConfirmVersionDialogProps { open: boolean; - /** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */ + /** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งสอง disable + แสดง spinner */ 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; } -/** 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 = ({ open, busy, + title, + description, + confirmLabel, onConfirm, onDismiss, }) => { + const [message, setMessage] = React.useState(""); + + // component ถูก mount ค้างไว้ตลอด (App render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด + // ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป + React.useEffect(() => { + if (!open) { + setMessage(""); + } + }, [open]); + return ( !data.open && !busy && onDismiss()} modalType="alert"> - สร้าง version ใหม่? + {title ?? DEFAULT_TITLE} - ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้ + {description ?? DEFAULT_DESCRIPTION} + +