Phase 11: version PDF (FR-15)

- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -10,17 +10,17 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
|---|---|---| |---|---|---|
| `/addin` | Office.js + TypeScript + React + Fluent UI (`yo office`) | Task pane ใน Word Desktop/Online — detect, Save & Version, history, field extraction | | `/addin` | Office.js + TypeScript + React + Fluent UI (`yo office`) | Task pane ใน Word Desktop/Online — detect, Save & Version, history, field extraction |
| `/backend` | ASP.NET Core 8 + EF Core + PostgreSQL + MinIO (AWSSDK.S3) + OpenXML | Version API, template system, docx generation, JWT validation | | `/backend` | ASP.NET Core 8 + EF Core + PostgreSQL + MinIO (AWSSDK.S3) + OpenXML | Version API, template system, docx generation, JWT validation |
| `/web` | Nuxt.js + TailwindCSS (light/dark theme) | จัดการ template + กรอก form สร้างเอกสารเป็น version 1 อัตโนมัติ | | `/web` | Nuxt.js + TailwindCSS (light/dark theme) | จัดการ template + กรอก form สร้างเอกสารเป็น version 0 ฉบับตั้งต้น |
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–10 เสร็จ** (implement ครบ + tests ครบชุด: addin 50 / web 46 / backend integration 60 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–17 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md) **สถานะปัจจุบัน: Phase 0–11 เสร็จครบ — FR-15 Version PDF: automated QA PASS (backend 68 / add-in 63 / web 55) + manual acceptance ผ่านบน Word for Mac (2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–14, acceptance criteria 1–17, data models, platform constraints) - `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–15, acceptance criteria 1–18, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 11 phases (0–10) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC - `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 12 phases (0–11) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–17 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ - `docs/acceptance-review.md` — ผลตรวจ AC 1–18 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -43,7 +43,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
## สถาปัตยกรรม — 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 → v0 (FR-10):** web form → `POST /api/documents/generate` → OpenXML แทนที่ค่าใน SDT + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ → สร้าง `Document` + `DocumentFieldValue` + upload v0 → ผู้ใช้โหลดไฟล์ไปเปิดใน Word → add-in ตรวจพบ tracked doc ทันทีเห็น v0
**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) **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)
@ -51,7 +51,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore **Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
**เอกสารของ template (FR-12):** `GET /api/templates/{id}/documents` (รายการต่อ template) + `GET /api/documents/{id}` (detail + ค่า fields ล่าสุด — merge นิยาม × ค่าแบบรองรับ orphan สองทาง) · re-generate ทำฝั่งเว็บ: ฟอร์มสร้างเอกสาร prefilled จากค่าปัจจุบัน (`?from={documentId}`) แล้วผ่าน endpoint generate เดิม → **เอกสารใหม่ v1 เอกสารต้นทางไม่ถูกแตะ** **เอกสารของ template (FR-12):** `GET /api/templates/{id}/documents` (รายการต่อ template) + `GET /api/documents/{id}` (detail + ค่า fields ล่าสุด — merge นิยาม × ค่าแบบรองรับ orphan สองทาง) · re-generate ทำฝั่งเว็บ: ฟอร์มสร้างเอกสาร prefilled จากค่าปัจจุบัน (`?from={documentId}`) แล้วผ่าน endpoint generate เดิม → **เอกสารใหม่ v0 เอกสารต้นทางไม่ถูกแตะ**
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง) **MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
@ -90,3 +90,9 @@ npm run dev
npm test npm test
npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว
``` ```
## Version PDF (FR-15 / spec v7)
เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF. Start Versioning และ Save & Version สร้าง v1+ พร้อม PDF จาก Word (`PdfFile 1.1` runtime guard; ไม่เพิ่ม requirement ใน manifest). PDF capture/upload เป็น best-effort: docx สำเร็จได้แม้ PDF ล้ม และ task pane เตือนตาม `hasPdf` จาก backend.
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.

View file

@ -10,17 +10,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|---|---|---| |---|---|---|
| `/addin` | Office.js + TypeScript + React + Fluent UI (`yo office`) | Task pane ใน Word Desktop/Online — detect, Save & Version, history, field extraction | | `/addin` | Office.js + TypeScript + React + Fluent UI (`yo office`) | Task pane ใน Word Desktop/Online — detect, Save & Version, history, field extraction |
| `/backend` | ASP.NET Core 8 + EF Core + PostgreSQL + MinIO (AWSSDK.S3) + OpenXML | Version API, template system, docx generation, JWT validation | | `/backend` | ASP.NET Core 8 + EF Core + PostgreSQL + MinIO (AWSSDK.S3) + OpenXML | Version API, template system, docx generation, JWT validation |
| `/web` | Nuxt.js + TailwindCSS (light/dark theme) | จัดการ template + กรอก form สร้างเอกสารเป็น version 1 อัตโนมัติ | | `/web` | Nuxt.js + TailwindCSS (light/dark theme) | จัดการ template + กรอก form สร้างเอกสารเป็น version 0 ฉบับตั้งต้น |
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–10 เสร็จ** (implement ครบ + tests ครบชุด: addin 50 / web 46 / backend integration 60 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–17 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md) **สถานะปัจจุบัน: Phase 0–11 เสร็จครบ — FR-15 Version PDF: automated QA PASS (backend 68 / add-in 63 / web 55) + manual acceptance ผ่านบน Word for Mac (2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–14, acceptance criteria 1–17, data models, platform constraints) - `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–15, acceptance criteria 1–18, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 11 phases (0–10) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC - `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 12 phases (0–11) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–17 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ - `docs/acceptance-review.md` — ผลตรวจ AC 1–18 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -43,7 +43,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
## สถาปัตยกรรม — 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 → v0 (FR-10):** web form → `POST /api/documents/generate` → OpenXML แทนที่ค่าใน SDT + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ → สร้าง `Document` + `DocumentFieldValue` + upload v0 → ผู้ใช้โหลดไฟล์ไปเปิดใน Word → add-in ตรวจพบ tracked doc ทันทีเห็น v0
**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) **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)
@ -51,7 +51,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore **Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
**เอกสารของ template (FR-12):** `GET /api/templates/{id}/documents` (รายการต่อ template) + `GET /api/documents/{id}` (detail + ค่า fields ล่าสุด — merge นิยาม × ค่าแบบรองรับ orphan สองทาง) · re-generate ทำฝั่งเว็บ: ฟอร์มสร้างเอกสาร prefilled จากค่าปัจจุบัน (`?from={documentId}`) แล้วผ่าน endpoint generate เดิม → **เอกสารใหม่ v1 เอกสารต้นทางไม่ถูกแตะ** **เอกสารของ template (FR-12):** `GET /api/templates/{id}/documents` (รายการต่อ template) + `GET /api/documents/{id}` (detail + ค่า fields ล่าสุด — merge นิยาม × ค่าแบบรองรับ orphan สองทาง) · re-generate ทำฝั่งเว็บ: ฟอร์มสร้างเอกสาร prefilled จากค่าปัจจุบัน (`?from={documentId}`) แล้วผ่าน endpoint generate เดิม → **เอกสารใหม่ v0 เอกสารต้นทางไม่ถูกแตะ**
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง) **MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
@ -90,3 +90,9 @@ npm run dev
npm test npm test
npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว
``` ```
## Version PDF (FR-15 / spec v7)
เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF. Start Versioning และ Save & Version สร้าง v1+ พร้อม PDF จาก Word (`PdfFile 1.1` runtime guard; ไม่เพิ่ม requirement ใน manifest). PDF capture/upload เป็น best-effort: docx สำเร็จได้แม้ PDF ล้ม และ task pane เตือนตาม `hasPdf` จาก backend.
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.

6
CONTEXT.md Normal file
View file

@ -0,0 +1,6 @@
# คำศัพท์ของ Word Versioning
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact

View file

@ -4,10 +4,10 @@
## คุณสมบัติหลัก ## คุณสมบัติหลัก
- 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 1 อัตโนมัติ** - 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 0 ฉบับตั้งต้น**
- 🗂️ **Template management** — upload ไฟล์ .docx ที่มี content controls แล้วระบบ **detect fields อัตโนมัติ** ตั้งชื่อ/ชนิด/required ต่อ field ผ่านหน้าเว็บ - 🗂️ **Template management** — upload ไฟล์ .docx ที่มี content controls แล้วระบบ **detect fields อัตโนมัติ** ตั้งชื่อ/ชนิด/required ต่อ field ผ่านหน้าเว็บ
- 📋 **รายการเอกสารต่อ template + detail** — ดูเอกสารทุกฉบับที่สร้างจาก template, ค่า fields ปัจจุบัน และ version history (ขยายแต่ละ version เห็นค่าตอนนั้น) บนหน้าเว็บ - 📋 **รายการเอกสารต่อ template + detail** — ดูเอกสารทุกฉบับที่สร้างจาก template, ค่า fields ปัจจุบัน และ version history (ขยายแต่ละ version เห็นค่าตอนนั้น) บนหน้าเว็บ
- ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v1 ของเอกสารใหม่ **เอกสารต้นทางและ version history ไม่ถูกแตะ** - ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v0 ของเอกสารใหม่ **เอกสารต้นทางและ 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 ทุกหน้า — เว้นว่างได้ - 💬 **คำอธิบายต่อ version (commit message)** — พิมพ์ข้อความอธิบายการเปลี่ยนแปลงได้ทุกครั้งที่สร้าง version (Save & Version, Start Versioning, สร้างจากเว็บ) แล้วแสดงกับ version นั้นใน history ทุกหน้า — เว้นว่างได้
@ -26,7 +26,7 @@
**Flow หลัก:** **Flow หลัก:**
1. Admin upload template (.docx ที่มี content controls) → ระบบ detect fields อัตโนมัติ 1. Admin upload template (.docx ที่มี content controls) → ระบบ detect fields อัตโนมัติ
2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 1 แล้ว → เปิดใน Word 2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 0 แล้ว → เปิดใน Word
3. Add-in รู้จักเอกสารทันที → แก้ไขเนื้อหา → กด **Save & Version** → เกิด version ใหม่ + fields ใน DB sync ตามเนื้อหาล่าสุด 3. Add-in รู้จักเอกสารทันที → แก้ไขเนื้อหา → กด **Save & Version** → เกิด version ใหม่ + fields ใน DB sync ตามเนื้อหาล่าสุด
4. กลับมาที่เว็บ anytime — ดูรายการเอกสารของ template / detail (ค่า fields + ทุก version) / **re-generate** เป็นเอกสารใหม่จากค่าปัจจุบัน 4. กลับมาที่เว็บ anytime — ดูรายการเอกสารของ template / detail (ค่า fields + ทุก version) / **re-generate** เป็นเอกสารใหม่จากค่าปัจจุบัน
@ -123,7 +123,7 @@ GET /api/documents/{documentId}/versions/{versionId}/download # ดา
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, 13) # Generate จาก template (FR-10, 13)
POST /api/documents/generate # { templateId, values, message? } → เอกสาร v1 พร้อม metadata POST /api/documents/generate # { templateId, values, message? } → เอกสาร v0 พร้อม metadata
# Templates (FR-9, 12) # Templates (FR-9, 12)
GET /api/templates # รายการ (?includeInactive=true สำหรับ admin) GET /api/templates # รายการ (?includeInactive=true สำหรับ admin)
@ -138,7 +138,7 @@ DELETE /api/templates/{id} # deactivate (s
- 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 ล้วน = ไม่เก็บ) - 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 เป็น v0 → response `{ documentId, versionId, versionNumber, downloadUrl }` — เปิดไฟล์ใน Word แล้ว add-in (Phase 4–5) จะรู้จักทันที
### ขอ token ทดสอบ (dev) ### ขอ token ทดสอบ (dev)
@ -187,7 +187,7 @@ 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** (แนบ `message` ได้เลย — เป็น commit message ของ v1): **3) Generate เอกสาร — ได้ .docx ที่เป็น version 0 พร้อม metadata** (แนบ `message` ได้เลย — เป็น commit message ของ v0):
```bash ```bash
curl -X POST http://localhost:5075/api/documents/generate \ curl -X POST http://localhost:5075/api/documents/generate \
@ -251,7 +251,7 @@ 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** (กดแล้วเปิด dialog ยืนยันพร้อมช่องคำอธิบายก่อนสร้าง v1 — FR-13) - **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v0 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **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 - **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 (ถ้ามี) + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์) - **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + คำอธิบาย version (ถ้ามี) + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์)
@ -280,12 +280,12 @@ cd web && npm test # Vitest — unit tests (46 ตัว)
| `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที | | `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที |
| `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/editor (FR-14 — `text` เลือก textbox/textarea ได้, number/date ใช้ control ตาม type)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) | | `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/editor (FR-14 — `text` เลือก textbox/textarea ได้, number/date ใช้ control ตาม type)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) |
| `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail | | `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail |
| `/documents/create/{templateId}` | dynamic form ตาม field definitions — control ต่อ field ตาม editor (FR-14: textarea สำหรับข้อความยาว · ช่องตัวเลข · date picker) + validate ฝั่ง client + backend + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v1 + ดาวน์โหลด | | `/documents/create/{templateId}` | dynamic form ตาม field definitions — control ต่อ field ตาม editor (FR-14: textarea สำหรับข้อความยาว · ช่องตัวเลข · date picker) + validate ฝั่ง client + backend + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v0 + เปิดใน Word |
| `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history พร้อมคำอธิบายของแต่ละ version (กดขยายแถวเห็นค่า 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/editor/required ให้เรียบร้อย (field ข้อความยาวเช่นที่อยู่/รายละเอียด เลือก editor เป็น `textarea` ได้ — FR-14) → กลับมาหน้าหลักเพื่อสร้างเอกสาร **สร้าง 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/editor/required ให้เรียบร้อย (field ข้อความยาวเช่นที่อยู่/รายละเอียด เลือก editor เป็น `textarea` ได้ — FR-14) → กลับมาหน้าหลักเพื่อสร้างเอกสาร
**Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน (ช่องคำอธิบาย version ว่าง — เป็นเอกสารใหม่ได้ข้อความใหม่) → กดสร้าง → ได้**เอกสารใหม่เป็น v1** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล) **Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน (ช่องคำอธิบาย version ว่าง — เป็นเอกสารใหม่ได้ข้อความใหม่) → กดสร้าง → ได้**เอกสารใหม่เป็น v0** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล)
Theme toggle (light/dark) อยู่มุมขวาบนของทุกหน้า (header ติด sticky) — จำค่าไว้หลัง reload Theme toggle (light/dark) อยู่มุมขวาบนของทุกหน้า (header ติด sticky) — จำค่าไว้หลัง reload
@ -317,9 +317,9 @@ npx vitest run test/useTheme.spec.ts # รันเดี
- **AuthTests** — ไม่แนบ token / token ขยะ → 401 ครบทุก endpoint (AC-3) - **AuthTests** — ไม่แนบ token / token ขยะ → 401 ครบทุก endpoint (AC-3)
- **UploadVersionTests** — upload v1/v2 + versionNumber เพิ่ม + history เรียงใหม่→เก่า + download ได้ bytes ตรงเป๊ะ + ไฟล์ไม่ใช่ docx → 400 + `CreatedByUserId` ตรง `sub` ใน JWT (AC-5, 6, 8) - **UploadVersionTests** — upload v1/v2 + versionNumber เพิ่ม + history เรียงใหม่→เก่า + download ได้ bytes ตรงเป๊ะ + ไฟล์ไม่ใช่ docx → 400 + `CreatedByUserId` ตรง `sub` ใน JWT (AC-5, 6, 8)
- **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` ลงไฟล์ + v0 + 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 - **VersionMessageTests** — commit message ต่อ version (FR-13/AC-16): เก็บ + echo ใน history · ว่าง/whitespace → null · trim · เกิน 2048 → 400 ไม่มี side effect · ยาวพอดี 2048 ผ่าน · message ต่อ version เป็นของตัวเอง · generate v0 พร้อม message · generate oversize → 400 ก่อนแตะ MinIO
- **FieldEditorTests** — editor ต่อ field (FR-14/AC-17): PUT editor=textarea → persist + echo · ไม่ส่ง → resolve default ตาม type + DB เก็บ null · ค่า = default → normalize เป็น null · editor ไม่รู้จัก/ไม่เข้ากับ type → 400 · tag ใหม่จาก replace ได้ default - **FieldEditorTests** — editor ต่อ field (FR-14/AC-17): PUT editor=textarea → persist + echo · ไม่ส่ง → resolve default ตาม type + DB เก็บ null · ค่า = default → normalize เป็น null · editor ไม่รู้จัก/ไม่เข้ากับ type → 400 · tag ใหม่จาก replace ได้ default
ไฟล์ .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
@ -351,3 +351,9 @@ npx vitest run test/useTheme.spec.ts # รันเดี
- **Backend**: .NET 8, EF Core + Npgsql (PostgreSQL), AWSSDK.S3 → MinIO (`ForcePathStyle = true`), DocumentFormat.OpenXml, JwtBearer - **Backend**: .NET 8, EF Core + Npgsql (PostgreSQL), AWSSDK.S3 → MinIO (`ForcePathStyle = true`), DocumentFormat.OpenXml, JwtBearer
- **Web**: Nuxt.js (Vue 3), TailwindCSS (`darkMode: 'class'`) - **Web**: Nuxt.js (Vue 3), TailwindCSS (`darkMode: 'class'`)
- **Auth**: Keycloak — realm `word-versioning`, clients: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto` (public + PKCE S256 ทุกตัว) - **Auth**: Keycloak — realm `word-versioning`, clients: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto` (public + PKCE S256 ทุกตัว)
## Version PDF (FR-15 / spec v7)
เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF. Start Versioning และ Save & Version สร้าง v1+ พร้อม PDF จาก Word (`PdfFile 1.1` runtime guard; ไม่เพิ่ม requirement ใน manifest). PDF capture/upload เป็น best-effort: docx สำเร็จได้แม้ PDF ล้ม และ task pane เตือนตาม `hasPdf` จาก backend.
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.

View file

@ -2,8 +2,9 @@
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase) * Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
*/ */
/** response ของ POST /api/documents/{documentId}/versions และ POST /api/documents/generate */ /** response ของ POST /api/documents/{documentId}/versions */
export interface UploadVersionResult { export interface UploadVersionResult {
hasPdf: boolean;
versionId: string; versionId: string;
versionNumber: number; versionNumber: number;
} }
@ -24,6 +25,7 @@ export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string,
/** item ของ GET /api/documents/{documentId}/versions */ /** item ของ GET /api/documents/{documentId}/versions */
export interface VersionRecord { export interface VersionRecord {
hasPdf?: boolean;
versionId: string; versionId: string;
versionNumber: number; versionNumber: number;
/** ISO-8601 */ /** ISO-8601 */

View file

@ -1,7 +1,13 @@
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService"; import {
getCurrentDocumentFile,
getCurrentDocumentPdf,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "./fileService";
interface FileMockState { interface FileMockState {
getFileCalls: number; getFileCalls: number;
fileTypes: unknown[];
requestedSlices: number[]; requestedSlices: number[];
closeCalls: number; closeCalls: number;
} }
@ -12,11 +18,17 @@ function installOfficeFileMock(
options: { options: {
failSliceIndex?: number; failSliceIndex?: number;
failClose?: boolean; failClose?: boolean;
unsupported?: boolean;
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */ /** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
sliceDataForm?: "buffer" | "view" | "array" | "base64"; sliceDataForm?: "buffer" | "view" | "array" | "base64";
} = {} } = {}
): FileMockState { ): FileMockState {
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 }; const state: FileMockState = {
fileTypes: [],
getFileCalls: 0,
requestedSlices: [],
closeCalls: 0,
};
const toSliceData = (bytes: Uint8Array): unknown => { const toSliceData = (bytes: Uint8Array): unknown => {
switch (options.sliceDataForm) { switch (options.sliceDataForm) {
@ -51,9 +63,10 @@ function installOfficeFileMock(
}; };
(globalThis as unknown as Record<string, unknown>).Office = { (globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" }, FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: { context: {
requirements: { isSetSupported: () => !options.unsupported },
document: { document: {
getFileAsync: ( getFileAsync: (
_fileType: unknown, _fileType: unknown,
@ -61,6 +74,7 @@ function installOfficeFileMock(
callback: (result: unknown) => void callback: (result: unknown) => void
) => { ) => {
state.getFileCalls++; state.getFileCalls++;
state.fileTypes.push(_fileType);
callback({ status: "succeeded", value: file }); callback({ status: "succeeded", value: file });
}, },
}, },
@ -135,3 +149,26 @@ describe("getCurrentDocumentFile", () => {
expect(blob.size).toBe(3); expect(blob.size).toBe(3);
}); });
}); });
describe("getCurrentDocumentPdf", () => {
it("captures PDF slices and closes the file", async () => {
const state = installOfficeFileMock([bytes(37, 80), bytes(68, 70)], {
sliceDataForm: "base64",
});
const pdf = await getCurrentDocumentPdf();
expect(pdf.type).toBe(PDF_CONTENT_TYPE);
expect(pdf.size).toBe(4);
expect(state.fileTypes).toEqual(["pdf"]);
expect(state.closeCalls).toBe(1);
});
it("rejects unsupported hosts without opening a file", async () => {
const state = installOfficeFileMock([], { unsupported: true });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.getFileCalls).toBe(0);
});
it("closes PDF even when slice reading fails", async () => {
const state = installOfficeFileMock([bytes(1)], { failSliceIndex: 0 });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.closeCalls).toBe(1);
});
});

View file

@ -9,26 +9,24 @@ export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"; "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */ /** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
export const PDF_CONTENT_TYPE = "application/pdf";
const SLICE_SIZE = 1024 * 1024; const SLICE_SIZE = 1024 * 1024;
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error { function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
return new Error(result.error?.message || fallback); return new Error(result.error?.message || fallback);
} }
function getFileAsync(): Promise<Office.File> { function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
Office.context.document.getFileAsync( Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
Office.FileType.Compressed,
{ sliceSize: SLICE_SIZE },
(result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) { if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value); resolve(result.value);
} else { } else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ")); reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
} }
} });
);
} catch (error) { } catch (error) {
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ")); reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
} }
@ -104,15 +102,26 @@ function closeFileQuietly(file: Office.File): Promise<void> {
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ * ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม) * เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
*/ */
export async function getCurrentDocumentFile(): Promise<Blob> { async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
const file = await getFileAsync(); const file = await getFileAsync(fileType);
try { try {
const chunks: BlobPart[] = []; const chunks: BlobPart[] = [];
for (let index = 0; index < file.sliceCount; index++) { for (let index = 0; index < file.sliceCount; index++) {
chunks.push(await getSliceAsync(file, index)); chunks.push(await getSliceAsync(file, index));
} }
return new Blob(chunks, { type: DOCX_CONTENT_TYPE }); return new Blob(chunks, { type: contentType });
} finally { } finally {
await closeFileQuietly(file); await closeFileQuietly(file);
} }
} }
export async function getCurrentDocumentFile(): Promise<Blob> {
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
}
export async function getCurrentDocumentPdf(): Promise<Blob> {
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
}
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
}

View file

@ -5,12 +5,17 @@ import { installWordMock } from "../test/wordMock";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */ /** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
function installOfficeDocumentMock(url: string | undefined): void { function installOfficeDocumentMock(
url: string | undefined,
options: { failPdf?: boolean; emptyPdf?: boolean } = {}
): string[] {
const requested: string[] = [];
const slice = new Uint8Array([1, 2, 3, 4]); const slice = new Uint8Array([1, 2, 3, 4]);
(globalThis as unknown as Record<string, unknown>).Office = { (globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" }, FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: { context: {
requirements: { isSetSupported: () => true },
document: { document: {
url, url,
getFileAsync: ( getFileAsync: (
@ -18,10 +23,15 @@ function installOfficeDocumentMock(url: string | undefined): void {
_options: unknown, _options: unknown,
callback: (result: unknown) => void callback: (result: unknown) => void
) => { ) => {
requested.push(String(_fileType));
if (_fileType === "pdf" && options.failPdf) {
callback({ status: "failed", error: { message: "PDF unavailable" } });
return;
}
callback({ callback({
status: "succeeded", status: "succeeded",
value: { value: {
sliceCount: 1, sliceCount: _fileType === "pdf" && options.emptyPdf ? 0 : 1,
getSliceAsync: (index: number, cb: (result: unknown) => void) => getSliceAsync: (index: number, cb: (result: unknown) => void) =>
cb({ status: "succeeded", value: { index, data: slice.buffer } }), cb({ status: "succeeded", value: { index, data: slice.buffer } }),
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }), closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
@ -31,6 +41,7 @@ function installOfficeDocumentMock(url: string | undefined): void {
}, },
}, },
}; };
return requested;
} }
function seedSession(): void { function seedSession(): void {
@ -266,3 +277,35 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
expect(form.get("message")).toBeNull(); expect(form.get("message")).toBeNull();
}); });
}); });
describe.each(["start", "save"])("FR-15 %s", (flow) => {
it.each(["success", "failure", "empty", "storage-failure"])(
"PDF %s preserves docx upload",
async (mode) => {
installWordMock({ properties: { DocumentTrackingId: "doc" } });
const requested = installOfficeDocumentMock(undefined, {
failPdf: mode === "failure",
emptyPdf: mode === "empty",
});
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({
status: 201,
body: { versionId: "v", versionNumber: 1, hasPdf: mode === "success" },
})
);
const result = flow === "start" ? await startVersioning() : await saveAndVersion("doc", 0);
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(requested).toEqual(["compressed", "pdf"]);
expect(form.get("file")).toBeInstanceOf(Blob);
if (mode === "success" || mode === "storage-failure") {
expect((form.get("pdf") as File).name).toMatch(/\.pdf$/);
expect((form.get("pdf") as File).type).toBe("application/pdf");
} else expect(form.get("pdf")).toBeNull();
expect(result.hasPdf).toBe(mode === "success");
expect(fetchMock).toHaveBeenCalledTimes(1);
warn.mockRestore();
}
);
});

View file

@ -1,7 +1,7 @@
/* global Word, Office */ /* global Word, Office, console */
import * as authService from "./authService"; import * as authService from "./authService";
import { getCurrentDocumentFile } from "./fileService"; import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
import { import {
generateGuid, generateGuid,
readContentControlFields, readContentControlFields,
@ -27,12 +27,14 @@ import { uploadVersion } from "./versioningService";
*/ */
export interface StartVersioningResult { export interface StartVersioningResult {
hasPdf: boolean;
documentId: string; documentId: string;
versionId: string; versionId: string;
versionNumber: number; versionNumber: number;
} }
export interface SaveAndVersionResult { export interface SaveAndVersionResult {
hasPdf: boolean;
versionId: string; versionId: string;
versionNumber: number; versionNumber: number;
} }
@ -71,10 +73,29 @@ async function uploadCurrentFile(
documentId: string, documentId: string,
clientVersionId: string, clientVersionId: string,
fields: Record<string, string>, fields: Record<string, string>,
message?: string message?: string,
): Promise<{ versionId: string; versionNumber: number }> { onProgress?: (progress: string) => void
): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile(); const file = await getCurrentDocumentFile();
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName(), message); let pdf: Blob | undefined;
onProgress?.("กำลังสร้างสำเนา PDF…");
try {
pdf = await getCurrentDocumentPdf();
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
} catch (error) {
console.warn("PDF capture failed:", error);
pdf = undefined;
}
onProgress?.("กำลังอัปโหลด version…");
return uploadVersion(
documentId,
file,
clientVersionId,
fields,
guessDocumentFileName(),
message,
pdf
);
} }
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 /** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
@ -109,7 +130,7 @@ export async function startVersioning(
// 4) upload version แรก // 4) upload version แรก
onProgress?.("กำลังอัปโหลด version 1…"); onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message); const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
return { documentId, ...result }; return { documentId, ...result };
} }
@ -147,7 +168,7 @@ export async function saveAndVersion(
// 4) upload // 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…"); onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message); const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) { if (result.versionNumber !== predictedNumber) {

View file

@ -0,0 +1,33 @@
import { getDownloadLink, uploadVersion } from "./versioningService";
import { apiGetJson, apiSendExpectJson } from "./apiClient";
jest.mock("./apiClient", () => ({ apiGetJson: jest.fn(), apiSendExpectJson: jest.fn() }));
it("requests PDF download-link and keeps docx as default", async () => {
await getDownloadLink("d", "v", "pdf");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link?format=pdf",
expect.any(String)
);
await getDownloadLink("d", "v");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link",
expect.any(String)
);
});
it("uploads optional PDF alongside the docx in one request", async () => {
await uploadVersion(
"d",
new Blob(["docx"]),
"v",
undefined,
undefined,
undefined,
new Blob(["pdf"], { type: "application/pdf" })
);
const form = (apiSendExpectJson as jest.Mock).mock.calls[0][1].body as FormData;
expect((form.get("pdf") as File).name).toBe("d.pdf");
expect((form.get("pdf") as File).type).toBe("application/pdf");
expect((form.get("file") as File).name).toBe("d.docx");
});

View file

@ -16,11 +16,13 @@ export async function uploadVersion(
clientVersionId: string, clientVersionId: string,
fields?: Record<string, string>, fields?: Record<string, string>,
fileName?: string, fileName?: string,
message?: string message?: string,
pdf?: Blob
): 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`);
form.append("clientVersionId", clientVersionId); form.append("clientVersionId", clientVersionId);
if (pdf) form.append("pdf", pdf, `${documentId}.pdf`);
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));
} }
@ -44,8 +46,12 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
} }
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */ /** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
export function buildDownloadUrl(documentId: string, versionId: string): string { export function buildDownloadUrl(
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`; documentId: string,
versionId: string,
format?: "docx" | "pdf"
): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download${format === "pdf" ? "?format=pdf" : ""}`;
} }
export interface VersionDownloadLink { export interface VersionDownloadLink {
@ -60,17 +66,22 @@ export interface VersionDownloadLink {
*/ */
export async function getDownloadLink( export async function getDownloadLink(
documentId: string, documentId: string,
versionId: string versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> { ): Promise<VersionDownloadLink> {
return apiGetJson<VersionDownloadLink>( return apiGetJson<VersionDownloadLink>(
`/api/documents/${documentId}/versions/${versionId}/download-link`, `/api/documents/${documentId}/versions/${versionId}/download-link${format === "pdf" ? "?format=pdf" : ""}`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ" "ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
); );
} }
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */ /** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> { export async function downloadVersion(
const response = await apiFetch(buildDownloadUrl(documentId, versionId)); documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId, format));
if (!response.ok) { if (!response.ok) {
throw new ApiError( throw new ApiError(
response.status, response.status,

View file

@ -16,7 +16,7 @@ import * as authService from "../../services/authService";
import { AuthRequiredError } from "../../services/apiClient"; import { AuthRequiredError } from "../../services/apiClient";
import { getErrorMessage } from "../../services/errors"; import { getErrorMessage } from "../../services/errors";
import * as metadataService from "../../services/metadataService"; import * as metadataService from "../../services/metadataService";
import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService"; import { getDownloadLink, getVersions } from "../../services/versioningService";
import { saveAndVersion, startVersioning } from "../../services/versionFlow"; import { saveAndVersion, startVersioning } from "../../services/versionFlow";
import { VersionRecord } from "../../models/versionRecord"; import { VersionRecord } from "../../models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog"; import ConfirmVersionDialog from "./ConfirmVersionDialog";
@ -89,6 +89,8 @@ const App: React.FC = () => {
const [confirmOpen, setConfirmOpen] = React.useState(false); const [confirmOpen, setConfirmOpen] = React.useState(false);
const [startConfirmOpen, setStartConfirmOpen] = 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 [flowWarning, setFlowWarning] = React.useState<string | null>(null);
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = React.useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null); const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null); const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null);
@ -215,11 +217,16 @@ const App: React.FC = () => {
setStarting(true); setStarting(true);
setFlowError(null); setFlowError(null);
setFlowSuccess(null); setFlowSuccess(null);
setFlowWarning(null);
try { try {
const result = await startVersioning(message, setProgressMessage); const result = await startVersioning(message, setProgressMessage);
setDocumentId(result.documentId); setDocumentId(result.documentId);
setPhase("tracking"); setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)"
);
await refreshHistory(result.documentId); await refreshHistory(result.documentId);
} catch (error) { } catch (error) {
if (!handleAuthRequired(error)) { if (!handleAuthRequired(error)) {
@ -240,10 +247,20 @@ const App: React.FC = () => {
setSaving(true); setSaving(true);
setFlowError(null); setFlowError(null);
setFlowSuccess(null); setFlowSuccess(null);
setFlowWarning(null);
try { try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0; const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage); const result = await saveAndVersion(
documentId,
latestVersionNumber,
message,
setProgressMessage
);
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`); setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)"
);
await refreshHistory(documentId); await refreshHistory(documentId);
} catch (error) { } catch (error) {
if (!handleAuthRequired(error)) { if (!handleAuthRequired(error)) {
@ -258,36 +275,33 @@ const App: React.FC = () => {
/** /**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน * FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download * เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob * แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
*/ */
const handleDownload = async (version: VersionRecord) => { const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
if (!documentId) { if (!documentId) {
return; return;
} }
setDownloadingVersionId(version.versionId); const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
setDownloading(version.versionId);
setFlowError(null); setFlowError(null);
try { try {
const link = await getDownloadLink(documentId, version.versionId); const link = await getDownloadLink(documentId, version.versionId, format);
if (typeof Office.context.ui.openBrowserWindow === "function") { if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url); Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`); setFlowSuccess(
format === "pdf"
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`
);
} else { } else {
const blob = await downloadVersion(documentId, version.versionId); throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `document_v${version.versionNumber}.docx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} }
} catch (error) { } catch (error) {
if (!handleAuthRequired(error)) { if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ")); setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
} }
} finally { } finally {
setDownloadingVersionId(null); setDownloading(null);
} }
}; };
@ -379,6 +393,11 @@ const App: React.FC = () => {
</MessageBar> </MessageBar>
) : null} ) : null}
{flowWarning ? (
<MessageBar intent="warning">
<MessageBarBody>{flowWarning}</MessageBarBody>
</MessageBar>
) : null}
{flowSuccess ? ( {flowSuccess ? (
<MessageBar intent="success"> <MessageBar intent="success">
<MessageBarBody>{flowSuccess}</MessageBarBody> <MessageBarBody>{flowSuccess}</MessageBarBody>
@ -415,6 +434,8 @@ const App: React.FC = () => {
loading={historyLoading} loading={historyLoading}
error={historyError} error={historyError}
downloadingVersionId={downloadingVersionId} downloadingVersionId={downloadingVersionId}
downloadingPdfVersionId={downloadingPdfVersionId}
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
onDownload={(version) => void handleDownload(version)} onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)} onRetry={() => documentId && void refreshHistory(documentId)}
/> />

View file

@ -9,7 +9,11 @@ import {
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowDownload24Regular, History24Regular } from "@fluentui/react-icons"; import {
ArrowDownload24Regular,
DocumentPdf24Regular,
History24Regular,
} from "@fluentui/react-icons";
import { VersionRecord } from "../../models/versionRecord"; import { VersionRecord } from "../../models/versionRecord";
interface VersionHistoryPanelProps { interface VersionHistoryPanelProps {
@ -18,6 +22,8 @@ interface VersionHistoryPanelProps {
error: string | null; error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */ /** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null; downloadingVersionId: string | null;
downloadingPdfVersionId: string | null;
onDownloadPdf: (version: VersionRecord) => void;
onDownload: (version: VersionRecord) => void; onDownload: (version: VersionRecord) => void;
onRetry: () => void; onRetry: () => void;
} }
@ -46,6 +52,7 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover, backgroundColor: tokens.colorNeutralBackground1Hover,
}, },
}, },
actions: { display: "flex", flexDirection: "column", flexShrink: 0 },
itemInfo: { itemInfo: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -76,6 +83,8 @@ export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
loading, loading,
error, error,
downloadingVersionId, downloadingVersionId,
downloadingPdfVersionId,
onDownloadPdf,
onDownload, onDownload,
onRetry, onRetry,
}) => { }) => {
@ -115,19 +124,30 @@ 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 ? ( {version.message ? <span className={styles.messageRow}>{version.message}</span> : null}
<span className={styles.messageRow}>{version.message}</span>
) : null}
</div> </div>
<div className={styles.actions}>
<Button <Button
icon={<ArrowDownload24Regular />} icon={<ArrowDownload24Regular />}
size="small" size="small"
appearance="subtle" appearance="subtle"
disabled={downloadingVersionId !== null} disabled={downloadingVersionId !== null || downloadingPdfVersionId !== null}
onClick={() => onDownload(version)} onClick={() => onDownload(version)}
> >
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"} {downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
</Button> </Button>
{version.hasPdf ? (
<Button
icon={<DocumentPdf24Regular />}
size="small"
appearance="subtle"
disabled={downloadingVersionId !== null || downloadingPdfVersionId !== null}
onClick={() => onDownloadPdf(version)}
>
{downloadingPdfVersionId === version.versionId ? "กำลังเปิด…" : "PDF"}
</Button>
) : null}
</div>
</div> </div>
))} ))}
</div> </div>

View file

@ -10,8 +10,8 @@ namespace WordVersioning.Api.IntegrationTests;
[Collection(IntegrationTestCollection.Name)] [Collection(IntegrationTestCollection.Name)]
public sealed class FieldSyncTests(IntegrationTestFixture fixture) public sealed class FieldSyncTests(IntegrationTestFixture fixture)
{ {
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary> /// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v0 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
private async Task<(HttpClient Client, Guid DocumentId, Guid V1VersionId)> CreateTrackedDocumentAsync() private async Task<(HttpClient Client, Guid DocumentId, Guid V0VersionId)> CreateTrackedDocumentAsync()
{ {
var client = fixture.CreateClient(); var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync"); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync");
@ -20,13 +20,13 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
} }
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม /// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary> /// + snapshot ของ version นั้นเก็บค่าใหม่ และ v0 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
[Fact] [Fact]
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion() public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
{ {
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync(); var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
var v2Fields = new Dictionary<string, string> var v1Fields = new Dictionary<string, string>
{ {
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว", ["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน ["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
@ -37,37 +37,37 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
$"api/documents/{documentId}/versions", $"api/documents/{documentId}/versions",
ApiExtensions.VersionForm( ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(), TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
JsonSerializer.Serialize(v2Fields))); JsonSerializer.Serialize(v1Fields)));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode); Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
var v2 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json); var v1 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check) // latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
// จึงชี้ SourceVersionId = v2 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v1 (ดู test AC-14) // จึงชี้ SourceVersionId = v1 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v0 (ดู test AC-14)
await using var db = fixture.CreateDb(); await using var db = fixture.CreateDb();
var values = await db.DocumentFieldValues.AsNoTracking() var values = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == documentId) .Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey); .ToDictionaryAsync(v => v.FieldKey);
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value); Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value);
Assert.Equal(v2!.VersionId, values["employee_name"].SourceVersionId); Assert.Equal(v1!.VersionId, values["employee_name"].SourceVersionId);
Assert.Equal("99000", values["salary"].Value); Assert.Equal("99000", values["salary"].Value);
Assert.Equal(v2.VersionId, values["salary"].SourceVersionId); Assert.Equal(v1.VersionId, values["salary"].SourceVersionId);
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v2 Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v1
Assert.Equal(v2.VersionId, values["position"].SourceVersionId); Assert.Equal(v1.VersionId, values["position"].SourceVersionId);
Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId); Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId);
// snapshot ต่อ version ผ่าน history API: v2 = ค่าใหม่, v1 = ค่าเดิม (missing=false ทั้งคู่) // snapshot ต่อ version ผ่าน history API: v1 = ค่าใหม่, v0 = ค่าเดิม (missing=false ทั้งคู่)
using var history = await client.GetAsync($"api/documents/{documentId}/versions"); using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))! var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))!
.OrderBy(i => i.VersionNumber) .OrderBy(i => i.VersionNumber)
.ToList(); .ToList();
var v1Snapshot = Snapshot(items[0].Fields!.Value); var v0Snapshot = Snapshot(items[0].Fields!.Value);
var v2Snapshot = Snapshot(items[1].Fields!.Value); var v1Snapshot = Snapshot(items[1].Fields!.Value);
Assert.Equal("สมหญิง รักงาน", v1Snapshot["employee_name"].Value); Assert.Equal("สมหญิง รักงาน", v0Snapshot["employee_name"].Value);
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v2Snapshot["employee_name"].Value); Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v1Snapshot["employee_name"].Value);
Assert.Equal("99000", v2Snapshot["salary"].Value); Assert.Equal("99000", v1Snapshot["salary"].Value);
Assert.All(v0Snapshot.Values, e => Assert.False(e.Missing));
Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing)); Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing));
Assert.All(v2Snapshot.Values, e => Assert.False(e.Missing));
} }
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing /// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
@ -75,7 +75,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
[Fact] [Fact]
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail() public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
{ {
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync(); var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field // ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
using var upload = await client.PostAsync( using var upload = await client.PostAsync(
@ -87,22 +87,22 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
// policy FR-11: ไม่ fail request // policy FR-11: ไม่ fail request
Assert.Equal(HttpStatusCode.Created, upload.StatusCode); Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
// latest values ของ field ที่หาย: คงค่า v1 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v1) // latest values ของ field ที่หาย: คงค่า v0 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v0)
await using var db = fixture.CreateDb(); await using var db = fixture.CreateDb();
var values = await db.DocumentFieldValues.AsNoTracking() var values = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == documentId) .Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey); .ToDictionaryAsync(v => v.FieldKey);
Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value); Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value);
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value);
Assert.Equal(v1VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา Assert.Equal(v0VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
Assert.Equal("75000", values["salary"].Value); Assert.Equal("75000", values["salary"].Value);
Assert.Equal("2026-08-01", values["start_date"].Value); Assert.Equal("2026-08-01", values["start_date"].Value);
Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ
// snapshot v2: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน // snapshot v1: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
using var history = await client.GetAsync($"api/documents/{documentId}/versions"); using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json); var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 2).Fields!.Value); var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 1).Fields!.Value);
Assert.False(snapshot["employee_name"].Missing); Assert.False(snapshot["employee_name"].Missing);
Assert.True(snapshot["position"].Missing); Assert.True(snapshot["position"].Missing);
@ -112,7 +112,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม" Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
} }
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1)</summary> /// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v0)</summary>
[Fact] [Fact]
public async Task DetailReflectsLatestSyncedValues() public async Task DetailReflectsLatestSyncedValues()
{ {

View file

@ -6,12 +6,12 @@ using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests; namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties /// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary> /// + Document/v0/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
[Collection(IntegrationTestCollection.Name)] [Collection(IntegrationTestCollection.Name)]
public sealed class GenerateTests(IntegrationTestFixture fixture) public sealed class GenerateTests(IntegrationTestFixture fixture)
{ {
[Fact] [Fact]
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV1() public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV0()
{ {
using var client = fixture.CreateClient(); using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก"); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
@ -19,7 +19,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
var doc = await TestFlow.GenerateDocumentAsync(client, templateId); var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที // response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
Assert.Equal(1, doc.VersionNumber); Assert.Equal(0, doc.VersionNumber);
Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl); Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl);
using var download = await client.GetAsync(doc.DownloadUrl); using var download = await client.GetAsync(doc.DownloadUrl);
Assert.Equal(HttpStatusCode.OK, download.StatusCode); Assert.Equal(HttpStatusCode.OK, download.StatusCode);
@ -43,7 +43,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn")); Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId")); Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) สถานะใน Postgres: Document ผูก template + v1 + field values ครบ // (4) สถานะใน Postgres: Document ผูก template + v0 + field values ครบ
await using var db = fixture.CreateDb(); await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking() var document = await db.Documents.AsNoTracking()
.Include(d => d.Versions) .Include(d => d.Versions)
@ -53,7 +53,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.EndsWith(".docx", document.FileName); Assert.EndsWith(".docx", document.FileName);
var version = Assert.Single(document.Versions); var version = Assert.Single(document.Versions);
Assert.Equal(1, version.VersionNumber); Assert.Equal(0, version.VersionNumber);
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
var fieldValues = await db.DocumentFieldValues.AsNoTracking() var fieldValues = await db.DocumentFieldValues.AsNoTracking()
@ -109,7 +109,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
}; };
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values); var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
Assert.Equal(1, doc.VersionNumber); Assert.Equal(0, doc.VersionNumber);
await using var db = fixture.CreateDb(); await using var db = fixture.CreateDb();
Assert.False(await db.DocumentFieldValues.AsNoTracking() Assert.False(await db.DocumentFieldValues.AsNoTracking()
@ -158,7 +158,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Null(byKey["note"].SourceVersionId); Assert.Null(byKey["note"].SourceVersionId);
Assert.Equal("note", byKey["note"].Label); Assert.Equal("note", byKey["note"].Label);
// def เดิมที่ยังอยู่ (employee_name) — ค่า v1 ยังอยู่ครบ // def เดิมที่ยังอยู่ (employee_name) — ค่า v0 ยังอยู่ครบ
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value); Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId); Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
@ -192,7 +192,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Equal(2, items!.Count); Assert.Equal(2, items!.Count);
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
Assert.Equal(2, items[0].VersionCount); Assert.Equal(2, items[0].VersionCount);
Assert.Equal(2, items[0].LatestVersionNumber); Assert.Equal(1, items[0].LatestVersionNumber);
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId); Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
Assert.Equal(1, items[1].VersionCount); Assert.Equal(1, items[1].VersionCount);
} }

View file

@ -5,11 +5,13 @@ using System.Text.Json;
namespace WordVersioning.Api.IntegrationTests; namespace WordVersioning.Api.IntegrationTests;
/// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary> /// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary>
public record UploadVersionResult(Guid VersionId, int VersionNumber); public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf);
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
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, string? Message); public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder); public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
@ -31,7 +33,7 @@ public static class ApiExtensions
{ {
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (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, string? message = null) byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null, byte[]? pdf = null)
{ {
var form = new MultipartFormDataContent(); var form = new MultipartFormDataContent();
var file = new ByteArrayContent(docx); var file = new ByteArrayContent(docx);
@ -42,6 +44,12 @@ public static class ApiExtensions
form.Add(new StringContent(fieldsJson), "fields"); form.Add(new StringContent(fieldsJson), "fields");
if (message is not null) if (message is not null)
form.Add(new StringContent(message), "message"); form.Add(new StringContent(message), "message");
if (pdf is not null)
{
var part = new ByteArrayContent(pdf);
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(part, "pdf", "document.pdf");
}
return form; return form;
} }

View file

@ -146,7 +146,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
} }
[Fact] [Fact]
public async Task Generate_WithMessage_CreatesV1WithMessage() public async Task Generate_WithMessage_CreatesV0WithMessage()
{ {
using var client = fixture.CreateClient(); using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen"); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen");
@ -157,7 +157,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
await using var db = fixture.CreateDb(); await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking() var version = await db.DocumentVersions.AsNoTracking()
.SingleAsync(v => v.DocumentId == generated.DocumentId); .SingleAsync(v => v.DocumentId == generated.DocumentId);
Assert.Equal(1, version.VersionNumber); Assert.Equal(0, version.VersionNumber);
Assert.Equal("ฉบับเสนอลงนามครั้งแรก", version.Message); Assert.Equal("ฉบับเสนอลงนามครั้งแรก", version.Message);
using var history = await client.GetAsync($"api/documents/{generated.DocumentId}/versions"); using var history = await client.GetAsync($"api/documents/{generated.DocumentId}/versions");

View file

@ -0,0 +1,171 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
[Collection(IntegrationTestCollection.Name)]
public sealed class VersionPdfTests(IntegrationTestFixture fixture)
{
private static readonly byte[] Pdf = "%PDF-1.7\nversion snapshot\n%%EOF"u8.ToArray();
[Fact]
public async Task Upload_WithPdf_PersistsSiblingKey_AndDownloadsExactBytes()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
var clientId = Guid.NewGuid().ToString();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", clientId, pdf: Pdf));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.True(result.HasPdf);
Assert.Equal(1, result.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == result.VersionId);
Assert.Equal($"{id}/v0001_{clientId}.pdf", row.PdfObjectKey);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{id}/versions");
Assert.True(history!.Single().HasPdf);
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/download?format=PDF");
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
Assert.Equal(Pdf, await download.Content.ReadAsByteArrayAsync());
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
using var browser = new HttpClient();
using var signedDownload = await browser.GetAsync(link!.Url);
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
using var signedDocx = await browser.GetAsync(docxLink!.Url);
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
}
[Fact]
public async Task Upload_WithoutPdf_HasFalse_AndBothPdfDownloadsReturn404()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString()));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.False(result.HasPdf);
await using var db = fixture.CreateDb();
Assert.Null((await db.DocumentVersions.SingleAsync(v => v.Id == result.VersionId)).PdfObjectKey);
foreach (var endpoint in new[] { "download", "download-link" })
{
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/{endpoint}?format=pdf");
Assert.Equal(HttpStatusCode.NotFound, download.StatusCode);
}
}
[Theory]
[InlineData("bad.txt", "application/pdf", false)]
[InlineData("doc.pdf", "text/plain", false)]
[InlineData("doc.pdf", "application/pdf", true)]
public async Task InvalidPdf_Returns400_BeforeCreatingDocument(string name, string type, bool empty)
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var form = ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString());
var pdf = new ByteArrayContent(empty ? [] : Pdf);
pdf.Headers.ContentType = new MediaTypeHeaderValue(type);
form.Add(pdf, "pdf", name);
using var response = await client.PostAsync($"api/documents/{id}/versions", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
}
[Fact]
public async Task Generate_V0WithoutPdf_ThenUpload_V1WithPdf()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "pdf-gen");
var generated = await TestFlow.GenerateDocumentAsync(client, templateId);
Assert.Equal(0, generated.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == generated.VersionId);
Assert.Null(row.PdfObjectKey);
Assert.StartsWith($"{generated.DocumentId}/v0000_", row.ObjectKey);
using var response = await client.PostAsync($"api/documents/{generated.DocumentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), pdf: Pdf));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.Equal(1, result.VersionNumber);
Assert.True(result.HasPdf);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{generated.DocumentId}/versions");
Assert.Equal(new[] { 1, 0 }, history!.Select(v => v.VersionNumber));
Assert.False(history[1].HasPdf);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task StorageFailure_PdfIsBestEffort_DocxStillRollsBack(bool failDocx)
{
await using var db = fixture.CreateDb();
var storage = new FailingStorage(failDocx);
var user = new TestUser();
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
new WordVersioning.Api.Services.HashService(), null!,
new WordVersioning.Api.Services.FieldSyncService(db, user), user,
Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
var id = Guid.NewGuid();
using var docxStream = new MemoryStream(TestDocx.BuildPlainDocx());
using var pdfStream = new MemoryStream(Pdf);
var request = new WordVersioning.Api.Models.UploadVersionRequest
{
File = new Microsoft.AspNetCore.Http.FormFile(docxStream, 0, docxStream.Length, "file", "doc.docx")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = TestDocx.DocxContentType },
Pdf = new Microsoft.AspNetCore.Http.FormFile(pdfStream, 0, pdfStream.Length, "pdf", "doc.pdf")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = "application/pdf" },
ClientVersionId = Guid.NewGuid().ToString(),
};
var response = await controller.UploadVersion(id, request, CancellationToken.None);
if (failDocx)
{
Assert.Equal(502, Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response).StatusCode);
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
Assert.False(await db.DocumentVersions.AnyAsync(v => v.DocumentId == id));
Assert.Single(storage.Keys);
}
else
{
var created = Assert.IsType<Microsoft.AspNetCore.Mvc.CreatedAtActionResult>(response);
Assert.False(Assert.IsType<WordVersioning.Api.Models.UploadVersionResponse>(created.Value).HasPdf);
await using var persisted = fixture.CreateDb();
var row = await persisted.DocumentVersions.SingleAsync(v => v.DocumentId == id);
Assert.Null(row.PdfObjectKey);
Assert.Equal(2, storage.Keys.Count);
Assert.EndsWith(".docx", storage.Keys[0]);
Assert.EndsWith(".pdf", storage.Keys[1]);
}
}
private sealed class TestUser : WordVersioning.Api.Services.ICurrentUser
{
public string UserId => "test";
public string? UserName => "test";
}
private sealed class FailingStorage(bool failDocx) : WordVersioning.Api.Services.IObjectStorageService
{
public List<string> Keys { get; } = [];
public Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
{
Keys.Add(key);
if (failDocx || key.EndsWith(".pdf")) throw new Amazon.Runtime.AmazonServiceException("Simulated storage failure");
return Task.CompletedTask;
}
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException();
}
}

View file

@ -37,6 +37,7 @@ public class DocumentsController(
private const int MaxVersionMessageLength = 2048; // FR-13 — ตรงกับ HasMaxLength ใน AppDbContext private const int MaxVersionMessageLength = 2048; // FR-13 — ตรงกับ HasMaxLength ใน AppDbContext
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี) /// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)
/// + FR-15: optional PDF; PDF storage failure still returns 201 with HasPdf=false
/// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary> /// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary>
[HttpPost("{documentId:guid}/versions")] [HttpPost("{documentId:guid}/versions")]
[RequestSizeLimit(MaxUploadBytes)] [RequestSizeLimit(MaxUploadBytes)]
@ -65,6 +66,21 @@ public class DocumentsController(
if (message is { Length: > MaxVersionMessageLength }) if (message is { Length: > MaxVersionMessageLength })
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ"); return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
if (request.Pdf is not null)
{
if (request.Pdf.Length > MaxUploadBytes)
return StatusCode(StatusCodes.Status413PayloadTooLarge, "ไฟล์ PDF ใหญ่เกิน 100MB");
if (request.Pdf.Length == 0 || !PdfUploadValidation.IsValidPdf(request.Pdf))
return BadRequest("ต้องแนบไฟล์ .pdf ที่ไม่ว่าง (application/pdf)");
}
await using var pdfStream = new MemoryStream();
if (request.Pdf is not null)
{
await request.Pdf.CopyToAsync(pdfStream, ct);
pdfStream.Position = 0;
}
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var fileName = request.File.FileName; var fileName = request.File.FileName;
@ -133,6 +149,7 @@ public class DocumentsController(
DocumentId = documentId, DocumentId = documentId,
VersionNumber = nextNumber, VersionNumber = nextNumber,
ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx", ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx",
PdfObjectKey = request.Pdf is null ? null : $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.pdf",
ContentHash = contentHash, ContentHash = contentHash,
CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
CreatedAt = now, CreatedAt = now,
@ -187,15 +204,31 @@ public class DocumentsController(
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
} }
// FR-15: PDF is derived; its storage failure must not undo the saved docx.
if (version.PdfObjectKey is not null)
{
try
{
await storage.PutAsync(minioOptions.Value.VersionsBucket, version.PdfObjectKey,
pdfStream, PdfUploadValidation.PdfContentType, ct);
}
catch (Exception ex) when (ex is AmazonServiceException or IOException or HttpRequestException or OperationCanceledException)
{
logger.LogWarning(ex, "PDF upload failed documentId={DocumentId} versionId={VersionId}", documentId, version.Id);
version.PdfObjectKey = null;
await db.SaveChangesAsync(CancellationToken.None);
}
}
logger.LogInformation( logger.LogInformation(
"Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}", "Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}",
documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash); documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash);
return CreatedAtAction(nameof(GetVersions), new { documentId }, return CreatedAtAction(nameof(GetVersions), new { documentId },
new UploadVersionResponse(version.Id, version.VersionNumber)); new UploadVersionResponse(version.Id, version.VersionNumber, version.PdfObjectKey is not null));
} }
/// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 1 /// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 0
/// + field values ทั้งหมดใน flow เดียว — เปิดใน Word แล้ว add-in ตรวจพบ tracked document ทันที (AC-12)</summary> /// + field values ทั้งหมดใน flow เดียว — เปิดใน Word แล้ว add-in ตรวจพบ tracked document ทันที (AC-12)</summary>
[HttpPost("generate")] [HttpPost("generate")]
[ProducesResponseType(typeof(GenerateDocumentResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(GenerateDocumentResponse), StatusCodes.Status201Created)]
@ -304,8 +337,8 @@ public class DocumentsController(
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
DocumentId = documentId, DocumentId = documentId,
VersionNumber = 1, VersionNumber = 0,
ObjectKey = $"{documentId}/v0001_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client) ObjectKey = $"{documentId}/v0000_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client)
ContentHash = contentHash, ContentHash = contentHash,
CreatedByUserId = currentUser.UserId, CreatedByUserId = currentUser.UserId,
CreatedAt = now, CreatedAt = now,
@ -313,7 +346,7 @@ public class DocumentsController(
}; };
db.DocumentVersions.Add(version); db.DocumentVersions.Add(version);
// snapshot + field values ของ v1 (ทุก field มีค่า = missing=false ทั้งหมด) // snapshot + field values ของ v0 (ทุก field มีค่า = missing=false ทั้งหมด)
var sync = await fieldSync.StageFieldSyncAsync(doc, version.Id, values, now, ct); var sync = await fieldSync.StageFieldSyncAsync(doc, version.Id, values, now, ct);
version.FieldsSnapshotJson = sync.SnapshotJson; version.FieldsSnapshotJson = sync.SnapshotJson;
@ -434,37 +467,43 @@ public class DocumentsController(
v.ContentHash, v.ContentHash,
v.FieldsSnapshotJson, v.FieldsSnapshotJson,
v.Message, v.Message,
v.PdfObjectKey,
}) })
.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), r.Message)).ToList()); ParseSnapshot(r.FieldsSnapshotJson), r.Message, r.PdfObjectKey is not null)).ToList());
} }
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary> /// <summary>FR-7/FR-15 — proxy download; format=pdf selects PDF (404 when absent), default is docx.</summary>
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download")] [HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct) public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
{ {
var row = await db.DocumentVersions var row = await db.DocumentVersions
.AsNoTracking() .AsNoTracking()
.Where(v => v.DocumentId == documentId && v.Id == versionId) .Where(v => v.DocumentId == documentId && v.Id == versionId)
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, d.FileName }) .Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, v.PdfObjectKey, d.FileName })
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (row is null) if (row is null)
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
if (objectKey is null)
return NotFound("version นี้ไม่มีสำเนา PDF");
Stream stream; Stream stream;
try try
{ {
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct); stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, objectKey, ct);
} }
catch (KeyNotFoundException) catch (KeyNotFoundException)
{ {
logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}", logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}",
documentId, versionId, row.ObjectKey); documentId, versionId, objectKey);
return NotFound("ไฟล์ของ version นี้ไม่พบใน storage"); return NotFound("ไฟล์ของ version นี้ไม่พบใน storage");
} }
@ -474,28 +513,38 @@ public class DocumentsController(
var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
? row.FileName ? row.FileName
: row.FileName + ".docx"; : row.FileName + ".docx";
return File(stream, DocxContentType, downloadName); return File(stream, isPdf ? PdfUploadValidation.PdfContentType : DocxContentType,
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
} }
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต) /// <summary>FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent).
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac) /// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary> /// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")] [HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink( public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
Guid documentId, Guid versionId, CancellationToken ct) Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
{ {
var objectKey = await db.DocumentVersions var row = await db.DocumentVersions
.AsNoTracking() .AsNoTracking()
.Where(v => v.DocumentId == documentId && v.Id == versionId) .Where(v => v.DocumentId == documentId && v.Id == versionId)
.Select(v => v.ObjectKey) .Join(db.Documents, v => v.DocumentId, d => d.Id,
(v, d) => new { v.ObjectKey, v.PdfObjectKey, v.VersionNumber, d.FileName })
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (objectKey is null) if (row is null)
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
if (objectKey is null)
return NotFound("version นี้ไม่มีสำเนา PDF");
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
? row.FileName[..^5] : row.FileName;
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600); var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds)); var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition);
logger.LogInformation( logger.LogInformation(
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}", "Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",

View file

@ -39,6 +39,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
entity.HasIndex(x => new { x.DocumentId, x.VersionNumber }).IsUnique(); entity.HasIndex(x => new { x.DocumentId, x.VersionNumber }).IsUnique();
entity.Property(x => x.ObjectKey).HasMaxLength(1024); entity.Property(x => x.ObjectKey).HasMaxLength(1024);
entity.Property(x => x.PdfObjectKey).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.Property(x => x.Message).HasMaxLength(2048); // FR-13 commit message — นโยบายเดียวกับ Template.Description

View file

@ -0,0 +1,264 @@
// <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("20260908053250_AddVersionPdf")]
partial class AddVersionPdf
{
/// <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<string>("PdfObjectKey")
.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<string>("Editor")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
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 AddVersionPdf : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PdfObjectKey",
table: "DocumentVersions",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PdfObjectKey",
table: "DocumentVersions");
}
}
}

View file

@ -112,6 +112,10 @@ namespace WordVersioning.Api.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)"); .HasColumnType("character varying(1024)");
b.Property<string>("PdfObjectKey")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("VersionNumber") b.Property<int>("VersionNumber")
.HasColumnType("integer"); .HasColumnType("integer");

View file

@ -9,12 +9,15 @@ public class DocumentVersion
public Document? Document { get; set; } public Document? Document { get; set; }
/// <summary>เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race)</summary> /// <summary>generate เริ่ม 0; add-in เริ่ม 1 — auto increment ต่อ document (unique index กัน race)</summary>
public int VersionNumber { get; set; } public int VersionNumber { get; set; }
/// <summary>MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx</summary> /// <summary>MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx</summary>
public string ObjectKey { get; set; } = string.Empty; public string ObjectKey { get; set; } = string.Empty;
/// <summary>FR-15: null เมื่อไม่มี PDF (v0 หรือ capture/upload ล้ม)</summary>
public string? PdfObjectKey { get; set; }
/// <summary>SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)</summary> /// <summary>SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)</summary>
public string ContentHash { get; set; } = string.Empty; public string ContentHash { get; set; } = string.Empty;

View file

@ -9,6 +9,8 @@ public class UploadVersionRequest
[Required] [Required]
public IFormFile File { get; set; } = null!; public IFormFile File { get; set; } = null!;
public IFormFile? Pdf { get; set; }
/// <summary>GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key</summary> /// <summary>GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key</summary>
[Required] [Required]
public string ClientVersionId { get; set; } = string.Empty; public string ClientVersionId { get; set; } = string.Empty;
@ -20,7 +22,7 @@ public class UploadVersionRequest
public string? Message { get; set; } public string? Message { get; set; }
} }
public record UploadVersionResponse(Guid VersionId, int VersionNumber); public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf);
/// <summary>body ของ POST /api/documents/generate (FR-10)</summary> /// <summary>body ของ POST /api/documents/generate (FR-10)</summary>
public class GenerateDocumentRequest public class GenerateDocumentRequest
@ -45,7 +47,8 @@ public record VersionListItem(
string CreatedByUserId, string CreatedByUserId,
string ContentHash, string ContentHash,
object? Fields, object? Fields,
string? Message); string? Message,
bool HasPdf);
/// <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

@ -15,7 +15,7 @@ public interface IObjectStorageService
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง /// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary> /// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry); string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
} }
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary> /// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
@ -76,7 +76,7 @@ public class ObjectStorageService : IObjectStorageService
return new S3ResponseStream(response); return new S3ResponseStream(response);
} }
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry) public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null)
{ {
var request = new GetPreSignedUrlRequest var request = new GetPreSignedUrlRequest
{ {
@ -85,6 +85,8 @@ public class ObjectStorageService : IObjectStorageService
Verb = HttpVerb.GET, Verb = HttpVerb.GET,
Expires = DateTime.UtcNow + expiry, Expires = DateTime.UtcNow + expiry,
}; };
if (contentDisposition is not null)
request.ResponseHeaderOverrides.ContentDisposition = contentDisposition;
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network) // GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
var url = _s3.GetPreSignedURL(request); var url = _s3.GetPreSignedURL(request);
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้ // AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้

View file

@ -0,0 +1,12 @@
namespace WordVersioning.Api.Services;
public static class PdfUploadValidation
{
public const string PdfContentType = "application/pdf";
public static bool IsValidPdf(IFormFile file) =>
file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
&& (string.IsNullOrWhiteSpace(file.ContentType)
|| file.ContentType.Equals(PdfContentType, StringComparison.OrdinalIgnoreCase)
|| file.ContentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase));
}

View file

@ -1,6 +1,6 @@
# ตรวจ Acceptance Criteria 1–17 (Phase 8 + 9 + 10) # ตรวจ Acceptance Criteria 1–18 (Phase 8 + 9 + 10 + 11)
> วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10 > วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · 2026-09-08 (AC-18) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
> >
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน > สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
@ -103,3 +103,35 @@
- [ ] 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 - [ ] (v5) ใน Word จริง: textarea ใน dialog ของทั้ง Save & Version และ Start Versioning + ข้อความโชว์ใน `VersionHistoryPanel` (AC-16) · และบน browser: ฟอร์มสร้างเอกสารพร้อม message + แสดงใน history ทั้ง desktop/mobille
- [ ] (v6) บน browser: หน้าแก้ template เลือก editor `textarea` (desktop + mobile) · ฟอร์มสร้างเอกสาร render textarea/ช่องตัวเลข/date picker ตาม editor · re-generate เอกสารที่ field date เก็บ timestamp เต็มแล้วช่อง date แสดงวันที่ถูกต้อง (AC-17) - [ ] (v6) บน browser: หน้าแก้ template เลือก editor `textarea` (desktop + mobile) · ฟอร์มสร้างเอกสาร render textarea/ช่องตัวเลข/date picker ตาม editor · re-generate เอกสารที่ field date เก็บ timestamp เต็มแล้วช่อง date แสดงวันที่ถูกต้อง (AC-17)
## AC-18 — Version PDF (v7)
**ผล: ✅ ผ่าน — automated verification + 🖐️ manual acceptance บน Word for Mac (2026-09-08)**. AC เดิมที่กล่าว generate=v1 ให้อ่านเป็น v0 ตาม spec v7; Start Versioning ยังคงเริ่ม v1.
| เกณฑ์ | หลักฐาน |
|---|---|
| generate v0 ไม่มี PDF → upload v1 มี PDF | `VersionPdfTests.Generate_V0WithoutPdf_ThenUpload_V1WithPdf`; migration `AddVersionPdf` ผ่าน PostgreSQL จริง |
| PDF sibling key, hasPdf, bytes/type/ชื่อไทย, presigned signature | `Upload_WithPdf_PersistsSiblingKey_AndDownloadsExactBytes` อ่าน proxy และ presigned URL จาก MinIO จริงทั้ง PDF/docx |
| ไม่มี PDF → false + 404 ทั้งสอง download endpoint | `Upload_WithoutPdf_HasFalse_AndBothPdfDownloadsReturn404` |
| นามสกุล/type ผิดหรือว่าง → 400 ก่อนสร้าง document | `InvalidPdf_Returns400_BeforeCreatingDocument` (3 cases) |
| PDF storage ล้ม → 201/false; docx storage ล้ม → 502/rollback | `StorageFailure_PdfIsBestEffort_DocxStillRollsBack` (2 cases; fault injection + PostgreSQL จริง) |
| ทั้งสอง add-in flows capture docx → PDF, fail/empty ไม่บล็อก upload | `versionFlow.test.ts` FR-15 matrix: start/save × success/failure/empty/storage-failure |
| PDF runtime guard, normalize slices, ปิด File object | `fileService.test.ts` PDF cases; manifest ไม่เพิ่ม requirement |
| multipart PDF + format query | `versioningService.test.ts` |
| URL ms-word คง signed URL, v0 filename, PDF path | `versionDownload.spec.ts` |
| PDF popup/fallback/reset/error และ Word timeout cleanup | `useVersionOpen.spec.ts` 6 lifecycle tests |
ชุดเต็ม: backend **68 ผ่าน**, add-in **63 ผ่าน** + build/lint ผ่าน, web **55 ผ่าน** + production build ผ่าน. Add-in build มี webpack bundle-size warnings; ไม่มี compile/lint errors.
### ยืนยันด้วยมือ — ผ่านบน Word for Mac (2026-09-08)
- [x] Mac: เว็บ v0 → เปิดใน Word → Save & Version → v1 + PDF ไม่มี warning, ปุ่ม PDF เฉพาะ v1 · Start Versioning → v1 + PDF ทันที
- [x] เทียบ PDF กับ Word Save As PDF: ฟอนต์ไทย TH Sarabun New render + embed, หัวท้ายกระดาษ — ตรง
- [x] Track Changes เปิดทิ้งไว้ → Save & Version → **PDF ไม่มี markup ปน** — ไม่มีข้อจำกัดต้องบันทึกเพิ่มใน spec
- [x] Warning path เมื่อ PDF capture ล้ม; ปุ่ม PDF ขึ้นเฉพาะ version ที่มี
- [x] เว็บ: docx เปิด Word ผ่าน `ms-word` พร้อม fallback ลิงก์สำรอง, PDF เปิดแท็บใหม่, ชื่อไฟล์สวยทั้งสองกรณี
- [ ] ค้างตามเดิม (ต้องมีเครื่อง/บัญชีทดสอบ): **Word for Windows + Word on the web** — sideload + full flow; เป็นข้อค้างเดิมตั้งแต่ AC-1 ไม่ใช่ของใหม่ใน AC-18
รายการเต็ม: [แผน FR-15](plans/version-pdf.md). ไม่มีการอ้างว่า unit tests ยืนยัน layout/Word protocol handler ได้.
QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending PDF tab cleanup, PDF success copy); 2026-09-08. Manual acceptance ผ่านบน Word for Mac 2026-09-08 — **Phase 11 ปิดแล้ว**; ค้างเฉพาะ Word for Windows / Word on the web ตามรายการด้านบน.

View file

@ -0,0 +1,12 @@
# ADR 0001: Version PDF ผลิตจาก Word เท่านั้น
วันที่: 2026-09-08 · สถานะ: Accepted
## Decision
Word ผ่าน add-in เป็นผู้ผลิต PDF เพียงรายเดียวเพื่อรักษา layout ของเอกสาร. PDF เป็น best-effort; บันทึก docx ได้แม้ PDF ล้ม. เว็บสร้างฉบับตั้งต้น v0 ไม่มี PDF; add-in เริ่ม v1. ไม่ backfill PDF หรือเปลี่ยนเลขข้อมูลเก่า.
## Alternatives rejected
Gotenberg/LibreOffice, Aspose, Microsoft Graph cloud และ Word Interop บน server: layout engine, ต้นทุน หรือ topology ไม่ตรงข้อตกลง.
## Consequences
v0 และ version เก่าไม่มี PDF; request มีขนาดเพิ่มขึ้น. เว็บเปิด docx ผ่าน ms-word protocol ซึ่งขึ้นกับ handler ของเครื่องผู้ใช้ จึงต้องมีดาวน์โหลดสำรอง. ตรวจฟอนต์ไทย/markup/fidelity บน Word จริง.

View file

@ -617,3 +617,17 @@ npm install
npm run dev npm run dev
npm test # vitest (46 ตัว) npm test # vitest (46 ตัว)
``` ```
## Phase 11: Version PDF + เลข version v0/v1+ — FR-15 (เพิ่มทีหลัง, v7)
| Phase | FR | AC |
|---|---|---|
| 11 (ทีหลัง, v7) | FR-15 (+แก้ FR-5/FR-7/FR-10) | AC-18 |
รายละเอียด: [version-pdf.md](version-pdf.md); ข้อความ generate=v1 ใน phase เดิมอ่านเป็น v0 ตาม spec v7
- [x] Spec v7 + glossary + ADR
- [x] Backend schema/API/storage + generate v0
- [x] Add-in PDF capture/warning/history
- [x] Web เปิด Word/PDF พร้อม fallback
- [x] Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55)
- [x] ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม

135
docs/plans/version-pdf.md Normal file
View file

@ -0,0 +1,135 @@
# Version PDF + เลข version v0/v1+ + เปิดไฟล์จากเว็บ — FR-15 / AC-18 / Spec v7
> **Status: เสร็จสมบูรณ์ — implementation + automated QA + manual acceptance ผ่านบน Word for Mac (2026-09-08)** · ค้างเฉพาะ Word for Windows / Word on the web ตามรายการใน [acceptance-review](../acceptance-review.md); spec v7
## Context
ระบบเก็บทุก version เป็น .docx ใน MinIO แต่การนำเอกสารไปใช้จริง โดยเฉพาะ**สำเนาเชิงกฎหมาย/ยื่นศาล** ต้องการ PDF ที่ฟอร์แมตเที่ยงเท่ากับที่ Word เอง export — ตัวแปลงกลาง (LibreOffice/Gotenberg, Aspose, Microsoft Graph, Word Interop บน server) ล้วนเป็น layout engine คนละตัวหรือมีต้นทุน/topology ที่ไม่เข้ากัน จึงตัดทิ้งทั้งหมด
**กลไกที่เลือก (ยืนยันจาก MS Learn):** `Office.context.document.getFileAsync(Office.FileType.Pdf)` — PdfFile requirement set รองรับ Word Windows/Mac/**web**/iPad · add-in ที่รันใน Word สั่ง export ได้โดยตรง และ `fileService` มี slice loop + `sliceDataToBytes()` รองรับ slice หลายรูปแบบอยู่แล้ว (กติกาข้อ 13) · known issue PDF ฝัง JS เป็นของ Excel/PPT เท่านั้น
พร้อมกันนี้ผู้ใช้ขอปรับสองเรื่อง: **(1) ระบบเลข version** — เว็บ-generate เป็น **v0 ฉบับตั้งต้น** (ไม่มี PDF), ทุกการ save จาก add-in เริ่ม **v1** และมี PDF เสมอ → เลข version บอกที่มา (0 = ฟอร์ม, ≥1 = ผ่าน Word) · **(2) คลิกลิงก์บนหน้าเว็บให้เปิดไฟล์ด้วยแอปของไฟล์นั้น** — PDF เปิดแท็บใหม่ (ตัวดู PDF ของเบราว์เซอร์), docx เปิด Word จริงผ่าน [`ms-word:ofe|u|<url>`](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes) (นิ่งบน Windows, Mac อาจต้องยืนยัน/พังบ้าง — มี fallback ดาวน์โหลดเสมอ)
## ข้อตกลง fixed (ยืนยันครบใน grill session)
1. **ผู้ผลิต PDF เดียวในระบบ = Word จริง** ผ่าน add-in — ไม่มี server-side converter ไม่ต้องเพิ่ม infra
2. **เลข version**: generate เว็บ → **v0** ฉบับตั้งต้น ไม่มี PDF · add-in save (**ทั้ง** Start Versioning และ Save & Version) → **v1, v2, …** มี PDF เสมอ · ไม่ backfill · ยังไม่มี production → ไม่ต้อง migration ข้อมูล dev เดิม (คงเลขเดิมหรือล้าง DB ก็ได้)
3. **Best-effort**: PDF capture/upload ล้ม → version ของ docx ผ่านเสมอ (`PdfObjectKey` = null) + MessageBar เตือนใน task pane "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ" — กด Save & Version ใหม่ได้ version ใหม่พร้อม PDF
4. **ที่เก็บ**: bucket `docx-versions` เดิม, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf` (stem เดียวกับ docx)
5. **UI**: ปุ่ม/ลิงก์ PDF เฉพาะ version ที่มี PDF จริง · add-in: presigned + `openBrowserWindow` (กติกาข้อ 12) · เว็บ: PDF แท็บใหม่ / docx ผ่าน `ms-word:ofe|u|` + heuristic ตรวจว่าเปิดจริง (visibilitychange/blur ~1.5s) ไม่เปิด → ลิงก์ presigned ดาวน์โหลดสำรอง
6. `pdf` part ที่ส่งมาไม่ใช่ .pdf → **400** (บั๊ก client ควรเห็นชัด) — ต่างจากความล้มสิ่งแวดล้อม (MinIO) ที่ best-effort
7. **manifest ไม่ declare `PdfFile`** เป็น requirement (ทำให้ host ไม่รองรับโหลด add-in ไม่ได้ทั้งอัน ขัด best-effort) — runtime-guard ด้วย `Office.context.requirements.isSetSupported("PdfFile", "1.1")` เท่านั้น
## ขั้นที่ 0 — บันทึกแผน + แก้เอกสาร requirement ก่อน (กติกา repo)
1. บันทึกแผนนี้ที่ `docs/plans/version-pdf.md` (ธรรมเนียมเดิม) — **ทำแล้ว**
2. **[specs/spec-officejs.md](../../specs/spec-officejs.md)** → v7: changelog บรรทัดบนสุด (FR-15 + เลข v0/v1 + เปิดไฟล์จากเว็บ) · **แก้ของเดิม**: FR-5 (response + pdf part + เลขเริ่ม v1 สำหรับ upload / v0 generate), FR-7 (download/download-link `?format=pdf` + presigned ฝังชื่อไฟล์ Content-Disposition), FR-10 ("version 1" → "version 0 ฉบับตั้งต้น"), AC 12/15 ที่อ้าง v1 ของ generate → v0 · `### FR-15: Version PDF — สำเนา PDF ที่ Word ผลิต (เพิ่มเติม v7)` ต่อท้ายหมวด 6: ผลิตจาก Word จริงเท่านั้นใน task pane (Save & Version + Start Versioning) · multipart part `pdf` ทางเลือก · sibling key `.pdf` · best-effort + คำเตือน · download `?format=pdf` (proxy + presigned 404 เมื่อไม่มี) · เว็บเปิด docx ผ่าน `ms-word:ofe|u|` และ PDF แท็บใหม่ · v0 ไม่มี PDF เสมอ · หมวด 7: `DocumentVersion.PdfObjectKey` + ปรับคำอธิบาย `VersionNumber` · หมวด 10: AC-18 (v7) · หมวด 11: task items (v7) ทั้ง 3 list
3. **[docs/plans/officejs-implementation-plan.md](officejs-implementation-plan.md)**: `## Phase 11: Version PDF + เลข version v0/v1+ — FR-15 (เพิ่มทีหลัง, v7)` + checklist + แถว `| 11 (ทีหลัง, v7) | FR-15 (+แก้ FR-5/FR-7/FR-10) | AC-18 |`
4. **[docs/acceptance-review.md](acceptance-review.md)**: AC-18 (verdict 🔶 รอ implement) + หมายเหตุว่า AC เดิมที่อ้าง "generate = v1" อ่านใหม่ตาม v7
5. **สร้าง `CONTEXT.md`** (glossary เท่านั้น ห้ามใส่ implementation): **Version PDF** = สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort · **ฉบับตั้งต้น (v0)** = version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word · **เลข version** = จำนวนรอบที่ผ่าน Word · **ต้นฉบับ (docx)** = ตัว version จริง; PDF เป็น derived artifact
6. **สร้าง `docs/adr/0001-version-pdf-from-word-only.md`**: Decision (Word เท่านั้น + v0 ไม่มี PDF + ไม่ backfill) · rejected: Gotenberg/LibreOffice, Aspose, Graph cloud, Word Interop server · consequences (v0/version เก่าไม่มี PDF, request โตขึ้น, ms-word พึ่ง protocol handler ของเครื่องผู้ใช้)
## ขั้นที่ 1 — Backend (`backend/WordVersioning.Api`)
1. **เลข version ใหม่**: [DocumentsController.cs](../../backend/WordVersioning.Api/Controllers/DocumentsController.cs) `Generate` — `VersionNumber = 0`, ObjectKey `v0000_{Guid}.docx` (สังเกต: `GenerateDocumentResponse.VersionNumber` คืน 0) · `UploadVersion` ใช้ `max+1` เดิมได้ทันที (0→1 และ ไม่มี→1 ได้ v1 ทั้งคู่) · add-in เดาเลข `latest+1` ใช้ได้กับ v0 ด้วย
2. [Models/DocumentVersion.cs](../../backend/WordVersioning.Api/Models/DocumentVersion.cs): `public string? PdfObjectKey { get; set; }` (+ doc comment FR-15: null = version นี้ไม่มี PDF — v0 หรือ capture/upload ไม่สำเร็จ)
3. [Data/AppDbContext.cs](../../backend/WordVersioning.Api/Data/AppDbContext.cs): `entity.Property(x => x.PdfObjectKey).HasMaxLength(1024);` · migration `dotnet ef migrations add AddVersionPdf` (tests ใช้ MigrateAsync อยู่แล้ว)
4. **สร้าง `Services/PdfUploadValidation.cs`** มิเรอร์ `DocxUploadValidation`: นามสกุล `.pdf` + content-type (`application/pdf`/octet-stream/ว่าง) — ความลึกเท่าของเดิม ไม่ต้อง magic-byte
5. [Models/VersionDtos.cs](../../backend/WordVersioning.Api/Models/VersionDtos.cs): `UploadVersionRequest.Pdf` (`IFormFile?` — bind part `pdf`) · `UploadVersionResponse` เพิ่ม `bool HasPdf` ท้าย · `VersionListItem` เพิ่ม `bool HasPdf` ท้าย · `GenerateDocumentResponse` **ไม่แตะ** (v0 ไม่มี PDF — asymmetry ตั้งใจ)
6. `UploadVersion`: validate part `pdf` หลัง message check (ไม่ใช่ .pdf/ว่าง → 400, ใหญ่เกิน → 413) · buffer เป็น MemoryStream (มิเรอร์ path docx) · ใน retry loop: `PdfObjectKey = มี pdf ? $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.pdf" : null` (คำนวณใน loop ให้ตรงเลขที่จองได้) · **หลัง** docx `PutAsync` (โค้ดเดิมไม่แตะ): put PDF ด้วย `application/pdf` — `AmazonServiceException` → log warning + `version.PdfObjectKey = null; SaveChangesAsync()` (ultra-rare fail → row ชี้ object ไม่มี = 404 ตอนโหลด ยอมรับได้) + **201 ต่อ** · response `UploadVersionResponse(..., PdfObjectKey is not null)` · matrix: docx put ล้ม → 502 rollback เหมือนเดิม / pdf put ล้ม → 201 hasPdf:false
7. `GetVersions`: projection เพิ่ม `PdfObjectKey` → `HasPdf` ท้าย VersionListItem
8. **Download ทั้งสอง endpoint เพิ่ม `string? format`**: `"pdf"` (case-insensitive; อื่น/null = docx — lenient ตามสไตล์ Clamp) → เลือก key จาก `PdfObjectKey`; ไม่มี → 404 "version นี้ไม่มีสำเนา PDF" · `DownloadVersion`: filename `.pdf` (`Path.ChangeExtension`) + content-type `application/pdf` · `GetVersionDownloadLink`: presign key ที่เลือก + **`ResponseHeaderOverrides.ContentDisposition`** ชื่อสวย (`inline; filename*=UTF-8''{base}_v{N}.pdf` / `attachment; filename*=UTF-8''{base}_v{N}.docx` — RFC 5987 สำหรับชื่อไทย, compose จาก `Document.FileName` + `VersionNumber` ฝั่ง server; add-in เจ้าเดิมก็ได้ชื่อสวยขึ้นไปด้วย) · update Swagger XML comments
## ขั้นที่ 2 — Add-in (`addin`)
1. [fileService.ts](../../addin/src/services/fileService.ts): parametrize capture เป็น private `readFileAsBlob(fileType, contentType)` (slice loop + `sliceDataToBytes` + `closeAsync` ใน finally เดิม) · `getCurrentDocumentFile()` public API เดิม · **เพิ่ม `getCurrentDocumentPdf()`** — guard `Office.context.requirements.isSetSupported("PdfFile", "1.1")` (throw ถ้าไม่รองรับ), คืน Blob type `application/pdf` · export `PDF_CONTENT_TYPE` · จับทีละ File object ตามลำดับ (Word เปิดพร้อมกันได้ 2 ตัว)
2. [versioningService.ts](../../addin/src/services/versioningService.ts): `uploadVersion(..., pdf?: Blob)` ท้าย signature → `if (pdf) form.append("pdf", pdf, \`${documentId}.pdf\`)` · `buildDownloadUrl`/`getDownloadLink`/`downloadVersion` เพิ่ม `format?: "docx" | "pdf"` → `?format=pdf`
3. [models/versionRecord.ts](../../addin/src/models/versionRecord.ts): `UploadVersionResult.hasPdf: boolean` · `VersionRecord.hasPdf?: boolean` (optional กัน payload เก่า)
4. [versionFlow.ts](../../addin/src/services/versionFlow.ts) — **ทั้งสอง flow** (v1+ มี PDF ทุกตัว): แก้ `uploadCurrentFile` ให้ทำเองทั้ง capture: docx → progress "กำลังสร้างสำเนา PDF…" → capture PDF ใน try/catch (**พัง = ข้าม + `console.warn` — กติกาข้อ 10, ห้ามบล็อกการบันทึก**) + guard `pdf.size === 0` (build เก่าคืนไฟล์เปล่า — [office-js#5950](https://github.com/OfficeDev/office-js/issues/5950)) → `uploadVersion(..., pdf)` ครั้งเดียว · `SaveAndVersionResult`/`StartVersioningResult` พา `hasPdf` จาก response ตรงๆ (backend echo คือ source เดียวของ warning — ไม่ต้องมี flag ท้องถิ่น) · step แก้เลข XML part หลัง upload เดิมทั้งหมด
5. [App.tsx](../../addin/src/taskpane/components/App.tsx): state `flowWarning` (เคลียร์พร้อม `flowSuccess`/`flowError`) — หลัง `saveAndVersion`/`startVersioning` สำเร็จ: `if (!result.hasPdf) setFlowWarning("บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)")` → render `<MessageBar intent="warning">` ใต้ success (Fluent UI v9) · `handleDownload(version, format)` + state `downloadingPdfVersionId` มิเรอร์ของเดิม (ส่ง format ให้ `getDownloadLink`/`downloadVersion`, ข้อความ/ชื่อไฟล์ fallback ตาม format)
6. [VersionHistoryPanel.tsx](../../addin/src/taskpane/components/VersionHistoryPanel.tsx): props เพิ่ม `downloadingPdfVersionId: string | null` + `onDownloadPdf: (version) => void` · แถวที่ `version.hasPdf` → Button `DocumentPdf24Regular` label "PDF" (`size="small" appearance="subtle"`, disabled ระหว่างโหลดตัวใดตัวหนึ่ง, wrap flex column ให้อยู่รอด task pane แคบ)
7. **manifest.xml ไม่แก้** (ข้อตกลง #7)
## ขั้นที่ 3 — Web (`web`)
1. [types/api.ts](../../web/app/types/api.ts): `VersionListItem.hasPdf: boolean` · `GenerateDocumentResponse` เดิม
2. **สร้าง `web/app/utils/versionDownload.ts`** (pure — ทดสอบได้ตาม pattern vitest ที่มี): `versionFileName(fileName, versionNumber, format)` (strip `.docx` + `_v{N}` + `.pdf`/`.docx`) · `versionDownloadPath(documentId, versionId, format)` (`?format=pdf` เมื่อ pdf) · `wordOpenUrl(presignedUrl)` → `ms-word:ofe|u|${url}` (ตรวจระดับ encode กับ [MS-DOCSTRURI](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes) ตอน implement)
3. [documents/[documentId].vue](../../web/app/pages/documents/[documentId].vue) — **เปลี่ยนพฤติกรรมจาก blob-download เป็นเปิดไฟล์**: `openVersion(v, format)` = `GET download-link` (Bearer) → PDF: `window.open(url, "_blank")` · DOCX: `window.open(wordOpenUrl(url))` + heuristic `visibilitychange`/`blur` ใน ~1.5s — ไม่เกิด = `AlertBox` พร้อมลิงก์ presigned ดาวน์โหลดสำรอง · state `openingVersionId`/`downloadError` แทน `downloadingVersionId` (proxy-blob flow ของหน้านี้เลิกใช้ — endpoint proxy ยังอยู่เพราะ add-in/spec ใช้) · ปุ่ม history (ตาราง + การ์ดมือถือ): "เปิด" ทุก version + "PDF" `v-if="v.hasPdf"` · Badge แสดง v0 ได้ตามข้อมูลจริง
4. [documents/create/[templateId].vue](../../web/app/pages/documents/create/[templateId].vue): ปุ่ม success เปลี่ยนเป็น **"เปิดใน Word"** (`ms-word` กับ v0 — next step ธรรมชาติของ flow: เปิด → แก้ → Save & Version เป็น v1) + ลิงก์ดาวน์โหลดสำรอง
## ขั้นที่ 4 — Tests
**Backend — ไฟล์ใหม่ `VersionPdfTests.cs`** (pattern `VersionMessageTests`) + **แก้ test เดิมที่ assert เลข version ของ generate** (GenerateTests ฯลฯ คาด v1 → v0, key `v0000_...`):
1. ขยาย helper ก่อน: `TestDtos` (+`HasPdf` ทั้ง upload/history, `DownloadLinkResult`) · `ApiExtensions.VersionForm` เพิ่ม `byte[]? pdf = null` (part `pdf` + content-type) · `TestDocx.BuildPdf()` (%PDF-1.7 + payload)
2. `Upload_WithPdf_PersistsSiblingKey_AndEchoesHasPdf` (key `{documentId}/v0001_{clientVersionId}.pdf`)
3. `Upload_WithoutPdf_StoresNullKey_HasPdfFalse`
4. `Upload_InvalidPdfPart_Returns400_NoSideEffect` (ชื่อไฟล์ไม่ใช่ .pdf)
5. `DownloadPdf_ReturnsExactBytes_PdfContentType_PdfFilename`
6. `DownloadPdf_WhenNoPdf_Returns404` · `DownloadLink_FormatPdf_NoPdf_Returns404`
7. `DownloadLink_FormatPdf_PresignedUrlWithFilenameDisposition`
8. `Generate_CreatesVersionZero_NoPdf` (VersionNumber == 0, PdfObjectKey null, `v0000_` key) · `UploadAfterGenerate_StartsAtVersionOne_WithPdf`
9. Regression: default (ไม่ส่ง format) ยังเป็น docx — ครอบโดย test download เดิม
**Add-in (jest)**: ขยาย `installOfficeFileMock` (fileService.test) / `installOfficeDocumentMock` (versionFlow.test) เพิ่ม `FileType.Pdf` + ตัวเลือก fail · [fileService.test.ts](../../addin/src/services/fileService.test.ts): `getCurrentDocumentPdf` คืน Blob type ใช่/เรียก FileType.Pdf/slice-shape อีกรูปแบบหนึ่ง/`isSetSupported` false → reject/`closeAsync` ถูกเรียกครั้งเดียวแม้พัง · [versionFlow.test.ts](../../addin/src/services/versionFlow.test.ts): **ทั้ง saveAndVersion และ startVersioning** แนบ part `pdf` ชื่อ `.pdf` + `requestedFileTypes` เรียง compressed → pdf · `failPdf` → ยัง upload สำเร็จ ไม่มี part pdf + `hasPdf:false` · test เดิม 5 ตัวไม่ต้องแก้ (assert เฉพาะ docx) · **สร้าง `versioningService.test.ts`**: `getDownloadLink(id, vid, "pdf")` ชน `?format=pdf` · `uploadVersion` แนบ pdf เมื่อได้รับ
**Web (vitest)**: `web/test/versionDownload.spec.ts` — ชื่อไฟล์ v0/v1 + format, path `?format=pdf`, `wordOpenUrl` encode ถูก · component เป็น manual verify (ไม่มี component-test infra — ไม่แกล้งทดสอบ)
## ขั้นที่ 5 — ปิดงาน
1. รันชุดเต็ม: `dotnet test` (backend) · `npm test` (addin) · `npm test` + `npm run build` (web)
2. [CLAUDE.md](../../CLAUDE.md): สถานะ Phase 0–11 · เพิ่มย่อหน้า "Version PDF (FR-15)" ในหมวดสถาปัตยกรรม + ปรับประโยค "เห็น v1" เป็นระบบ v0/v1+ · อัปเดต AGENTS.md ให้ตรงกัน
3. [docs/acceptance-review.md](acceptance-review.md): verdict AC-18 + หลักฐาน test + รายการยืนยันมือที่เหลือ
## ยืนยันมือที่ทำแทนไม่ได้ (ไว้ตรวจตอนปิด phase)
1. Word Windows: generate จากเว็บ (ได้ **v0** ไม่มี PDF) → "เปิดใน Word" จากหน้า success (ทดสอบ `ms-word` จริง) → แก้ → Save & Version → **v1 + PDF** ไม่มี warning + ปุ่ม PDF ขึ้นเฉพาะ v1
2. Start Versioning เอกสารนำเข้า → v1 **มี PDF** ทันที
3. **Fidelity**: เทียบ PDF กับ File → Save As PDF ของ Word — ฟอนต์ไทย (TH Sarabun New) render + embed (Acrobat → Properties → Fonts), หัว-ท้ายกระดาษตรง
4. **Tracked changes**: เปิด Track Changes → แก้ → Save & Version → PDF มี markup ปนไหม (ไม่มี API คุม — ถ้ามี จดใน AC-18 + spec แนะนำ accept/reject ก่อนบันทึกสำเนาศาล)
5. Word Mac + **Word on the web**: ซ้ำข้อ 1 (web = path ใหม่ของ PdfFile; slice shape ต่าง host)
6. **Warning path**: บังคับ PDF capture ล้ม → version ผ่าน + MessageBar เตือน + ไม่มีปุ่ม PDF ทั้งสอง UI + บันทึกใหม่ได้ PDF
7. **เว็บเปิดไฟล์**: PDF → แท็บใหม่เรนเดอร์ทันที · docx → Word เปิดจริง (Windows) / เครื่องไม่มี handler → เห็นลิงก์สำรอง · ชื่อไฟล์สวยทั้งสองกรณี
8. Regression: flow docx เดิม (AC-7/8/13) + history ทุกหน้าเรียง/แสดงเลขถูกตามระบบใหม่
## ความเสี่ยงที่รับไว้ (ตัดสินแล้ว)
- 100MB request cap ครอบ docx+pdf รวมกัน — คงตาม spec เดิม, เจอจริงค่อยปรับ
- `ms-word:` พึ่ง protocol handler ของเครื่องผู้ใช้ — Windows นิ่ง, Mac อาจต้องยืนยัน/พัง → heuristic fallback เสมอ
- apiClient ไม่มี timeout (เป็นมาก่อนกับ docx) — ไม่ขยายงานนี้
- Dev data เก่าใช้เลขเดิม (generate=v1) — ยอมรับได้เพราะยังไม่มี production หรือล้าง DB dev
## Implementation record — 2026-09-08
- [x] ขั้น 0: spec v7 + Phase 11 + glossary + ADR
- [x] Backend: PdfObjectKey migration, optional PDF validation/storage/download, hasPdf, generate v0
- [x] Add-in: PDF ทั้งสอง flow + warnings + history PDF; ไม่มี blob fallback ใน task pane
- [x] Web: shared `useVersionOpen`, เปิด Word/PDF + fallback + pending-tab cleanup
- [x] Automated checks: backend 68 / add-in 63 / web 55; builds ผ่าน, add-in lint ผ่าน
- [x] QA รอบยืนยัน — PASS
- [x] Manual acceptance บน Word for Mac ผ่าน (2026-09-08) — ผลละเอียดในหมวด Manual acceptance ด้านล่าง; Windows / Word on the web คงค้างตามรายการเดิม
ข้อปรับรายละเอียดจากแผน: ใช้ `DocumentPdf24Regular` ที่ package มีจริง; คง signed URL โดยไม่ encode ทั้งก้อนตาม [Microsoft Office URI Schemes](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes); reserve PDF tab ก่อน await เพื่อรองรับ popup policy; เพิ่ม composable lifecycle tests โดยไม่เพิ่ม component-test framework.
## QA
### Dev database follow-up — 2026-09-08
พบ `42703: column "PdfObjectKey" does not exist` ตอน Generate: migration ถูกสร้างและผ่าน Testcontainers แต่ยังไม่ได้ apply กับฐานข้อมูล dev `wordversioning`. ตรวจซ้ำด้วย `SELECT "PdfObjectKey" FROM "DocumentVersions" LIMIT 0` แล้วได้ error เดียวกัน; migration history มีถึง `AddTemplateFieldEditor` เท่านั้น และ config ชี้ฐานข้อมูลเดียวกัน. ลง `20260908053250_AddVersionPdf` ใน dev แล้วสำเร็จ. ก่อนทดสอบกับฐานข้อมูลเดิมหลังเพิ่ม migration ต้องรัน `dotnet ef database update` ตาม README; automated tests ที่สร้าง DB ใหม่ไม่ยืนยันสถานะ migration ของ dev DB.
2026-09-08 · fresh-eyes reviewer `qa_fr15` · 2 rounds · พบ 3 ข้อ แก้ครบ 3 ไม่มี rejected findings.
1. Build: test ใช้ `.at(-1)` เกิน TS target → indexed access; build ผ่าน
2. Lifecycle: pending PDF tab ค้างเมื่อออกจากหน้า → ปิดทันทีตอน reset/unmount; test ครอบ cleanup/late response
3. Copy: PDF inline แต่ข้อความบอกดู Downloads → ข้อความระบุเปิด PDF ใน browser
**Final verdict: PASS (implementation/automated QA + manual acceptance บน Word for Mac).** Phase 11 ปิดแล้ว.
## Manual acceptance — 2026-09-08 (Word for Mac)
ผู้ใช้ยืนยันผ่านครบตามรายการ "ยืนยันมือที่ทำแทนไม่ได้" บน Mac (ข้อ 1–4, 6–8):
- **ข้อ 1–2 flow:** เว็บ generate → v0 ไม่มี PDF → เปิดใน Word → Save & Version → **v1 + PDF** ไม่มี warning, ปุ่ม PDF ขึ้นเฉพาะ v1 · Start Versioning เอกสารนำเข้า → v1 มี PDF ทันที
- **ข้อ 3 fidelity:** เทียบ PDF จาก add-in กับ File → Save As PDF ของ Word — ฟอนต์ไทย (TH Sarabun New) render + embed ครบ, หัว-ท้ายกระดาษตรง
- **ข้อ 4 Track Changes:** เปิด Track Changes ทิ้งไว้ → แก้ → Save & Version → **PDF ไม่มี markup ของ revision ปน** — ไม่มีข้อจำกัดต้องบันทึกเพิ่มใน AC-18/spec
- **ข้อ 6 warning path · ข้อ 7 เว็บเปิดไฟล์** (PDF แท็บใหม่, docx ผ่าน `ms-word` พร้อม fallback, ชื่อไฟล์สวย) **· ข้อ 8 regression** flow docx เดิม — ผ่าน
- **ค้างตามเดิม (ข้อ 5):** Word for Windows + Word on the web — ต้องมีเครื่อง/บัญชีทดสอบ เป็นข้อค้างเดิมตั้งแต่ Phase 8 (AC-1 ฯลฯ) ไม่ใช่สิ่งที่ Phase 11 เพิ่มเข้ามา

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)
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow > ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
> ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า > ฉบับแก้ไข 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)
@ -12,7 +13,7 @@
1. **Office Add-in (Office.js, TypeScript)** — รันได้บน Word Desktop (Windows/Mac) และ Word Online โดยไม่ต้อง build แยก 1. **Office Add-in (Office.js, TypeScript)** — รันได้บน Word Desktop (Windows/Mac) และ Word Online โดยไม่ต้อง build แยก
2. **.NET 8 Web API Backend** — เก็บ version metadata ใน DB และไฟล์ .docx จริงใน **MinIO (S3-compatible object storage)** + ระบบ template และ generate เอกสาร 2. **.NET 8 Web API Backend** — เก็บ version metadata ใน DB และไฟล์ .docx จริงใน **MinIO (S3-compatible object storage)** + ระบบ template และ generate เอกสาร
3. **Web Portal (Nuxt.js + TailwindCSS)** — เว็บแยกสำหรับจัดการ template และกรอก form สร้างเอกสารครั้งแรกจาก template โดยเป็น **version 1 อัตโนมัติ** 3. **Web Portal (Nuxt.js + TailwindCSS)** — เว็บแยกสำหรับจัดการ template และกรอก form สร้างเอกสารครั้งแรกจาก template โดยเป็น **version 0 ฉบับตั้งต้น**
ความสามารถหลัก (เทียบเท่า VSTO แต่ปรับ flow ให้เข้ากับข้อจำกัดของแพลตฟอร์ม): ความสามารถหลัก (เทียบเท่า VSTO แต่ปรับ flow ให้เข้ากับข้อจำกัดของแพลตฟอร์ม):
@ -21,7 +22,7 @@
3. เมื่อกด Save & Version จะ stamp tag ลง metadata ของไฟล์ + อัปโหลด snapshot ไป backend **+ extract ค่าจาก content controls ไป update fields ใน DB ให้ sync (FR-11)** 3. เมื่อกด Save & Version จะ stamp tag ลง metadata ของไฟล์ + อัปโหลด snapshot ไป backend **+ extract ค่าจาก content controls ไป update fields ใน DB ให้ sync (FR-11)**
4. Backend เก็บ version history แบบ query ได้, ดาวน์โหลด version ย้อนหลังได้ 4. Backend เก็บ version history แบบ query ได้, ดาวน์โหลด version ย้อนหลังได้
5. จัดการ template เอกสาร (.docx ที่มี content controls) ผ่านหน้าเว็บ — upload แล้วระบบ **detect fields อัตโนมัติ** (FR-9) 5. จัดการ template เอกสาร (.docx ที่มี content controls) ผ่านหน้าเว็บ — upload แล้วระบบ **detect fields อัตโนมัติ** (FR-9)
6. สร้างเอกสารครั้งแรกจาก template: กรอก form บนเว็บ → backend generate .docx + stamp metadata + สร้าง **version 1 อัตโนมัติ** → เปิดใน Word แล้ว add-in รู้จักทันที (FR-10) 6. สร้างเอกสารครั้งแรกจาก template: กรอก form บนเว็บ → backend generate .docx + stamp metadata + สร้าง **version 0 ฉบับตั้งต้น** → เปิดใน Word แล้ว add-in รู้จักทันที (FR-10)
**ขอบเขต**: รองรับ Word Desktop (Win/Mac) + Word on the Web **ขอบเขต**: รองรับ Word Desktop (Win/Mac) + Word on the Web
@ -160,24 +161,26 @@ Flow เมื่อกด:
## 6. Functional Requirements — Backend API, Template System & Web Portal ## 6. Functional Requirements — Backend API, Template System & Web Portal
### FR-5: Upload version ### FR-5: Upload version
- v7: รับ multipart `pdf` ทางเลือก; response เพิ่ม `hasPdf`; upload เริ่ม v1 หรือ max+1 ต่อจาก v0 ที่ generate
``` ```
POST /api/documents/{documentId}/versions POST /api/documents/{documentId}/versions
Content-Type: multipart/form-data Content-Type: multipart/form-data
Body: file (docx binary), clientVersionId (guid) Body: file (docx binary), clientVersionId (guid), pdf? (PDF binary), fields?, message?
``` ```
- ถ้า `documentId` ยังไม่มีใน DB ให้สร้าง `Document` record ใหม่ - ถ้า `documentId` ยังไม่มีใน DB ให้สร้าง `Document` record ใหม่
- อัปโหลดไฟล์ไป MinIO bucket (เช่น `docx-versions`) ที่ object key: `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx` - อัปโหลดไฟล์ไป MinIO bucket (เช่น `docx-versions`) ที่ object key: `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`
- คำนวณ SHA256 hash ของไฟล์ เก็บลง DB (ใช้ตรวจสอบไฟล์ซ้ำ/integrity ภายหลัง) - คำนวณ SHA256 hash ของไฟล์ เก็บลง DB (ใช้ตรวจสอบไฟล์ซ้ำ/integrity ภายหลัง)
- บันทึก `DocumentVersion` record (version number = auto increment ต่อ document) - บันทึก `DocumentVersion` record (version number = auto increment ต่อ document)
- Response: `{ versionId, versionNumber }` - Response: `{ versionId, versionNumber, hasPdf }`
### FR-6: ดึง version history ### FR-6: ดึง version history
``` ```
GET /api/documents/{documentId}/versions GET /api/documents/{documentId}/versions
``` ```
- คืน list ของ `DocumentVersion` เรียงจากใหม่ไปเก่า - คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม `hasPdf` ระบุว่ามีสำเนา PDF
### FR-7: ดาวน์โหลด version ที่ระบุ ### FR-7: ดาวน์โหลด version ที่ระบุ
- v7: ทั้ง `/download` และ `/download-link` รับ `?format=pdf` (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; presigned URL มี Content-Disposition ชื่อ `{base}_v{N}.docx` (attachment) / `.pdf` (inline), รองรับชื่อไทยด้วย RFC 5987
``` ```
GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger) GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger)
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้) GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้)
@ -240,9 +243,9 @@ DELETE /api/templates/{id} # deactivate (soft-delete)
POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } } POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
``` ```
- Validate ก่อน: required fields ครบ, type ถูก (number/date parse ได้) - Validate ก่อน: required fields ครบ, type ถูก (number/date parse ได้)
- `DocxGenerationService` (OpenXML): โหลด template จาก MinIO → **แทนที่ข้อความใน SDT แต่ละ field ที่ run level (รักษา formatting ของ template)** → **stamp Custom Properties** (`SavedByAddIn` = GUID ของ add-in, `DocumentTrackingId` = GUID ใหม่) ลงไฟล์ — ทำฝั่ง backend เท่านั้น เพื่อให้ v1 + metadata เกิดพร้อมกันแบบ atomic ไฟล์ที่ดาวน์โหลดถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก - `DocxGenerationService` (OpenXML): โหลด template จาก MinIO → **แทนที่ข้อความใน SDT แต่ละ field ที่ run level (รักษา formatting ของ template)** → **stamp Custom Properties** (`SavedByAddIn` = GUID ของ add-in, `DocumentTrackingId` = GUID ใหม่) ลงไฟล์ — ทำฝั่ง backend เท่านั้น เพื่อให้ v0 + metadata เกิดพร้อมกันแบบ atomic ไฟล์ที่ดาวน์โหลดถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก
- สร้าง `Document` record (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **อัปโหลดเป็น version 1** + snapshot fields ของ v1 - สร้าง `Document` record (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **อัปโหลดเป็น version 0** + snapshot fields ของ v0
- Response `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 ทำงานทันที** (เป็น tracked document เห็น v1 โดยไม่ต้องกด Start Versioning) - Response `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 ทำงานทันที** (เป็น tracked document เห็น v0 โดยไม่ต้องกด Start Versioning)
### FR-11: Field sync — update fields ใน DB ตอนสร้าง version (เพิ่มเติม v2) ### FR-11: Field sync — update fields ใน DB ตอนสร้าง version (เพิ่มเติม v2)
- ขยาย `POST /api/documents/{documentId}/versions` ให้รับ `fields` (map tag→text ที่ add-in extract มาตาม FR-2 ขั้นตอนที่ 6) - ขยาย `POST /api/documents/{documentId}/versions` ให้รับ `fields` (map tag→text ที่ add-in extract มาตาม FR-2 ขั้นตอนที่ 6)
@ -254,7 +257,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- `GET /api/templates/{templateId}/documents` — รายการเอกสารที่สร้างจาก template หนึ่งตัว (ชื่อไฟล์, จำนวน version, version ล่าสุด + เวลา) เรียงตามกิจกรรมล่าสุด — ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม FR-9) - `GET /api/templates/{templateId}/documents` — รายการเอกสารที่สร้างจาก template หนึ่งตัว (ชื่อไฟล์, จำนวน version, version ล่าสุด + เวลา) เรียงตามกิจกรรมล่าสุด — ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม FR-9)
- `GET /api/documents/{documentId}` — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + **ค่าล่าสุดของทุก field** (นิยามจาก `TemplateField` merge กับ `DocumentFieldValue`) — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว: field ที่เพิ่มเข้าทีหลังแสดงค่าว่าง, ค่าที่ key ไม่มีในนิยามปัจจุบันยังแสดงโดยไม่มี label - `GET /api/documents/{documentId}` — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + **ค่าล่าสุดของทุก field** (นิยามจาก `TemplateField` merge กับ `DocumentFieldValue`) — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว: field ที่เพิ่มเข้าทีหลังแสดงค่าว่าง, ค่าที่ key ไม่มีในนิยามปัจจุบันยังแสดงโดยไม่มี label
- หน้าเว็บ (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** ให้แก้ไขก่อน → สร้างเป็น **เอกสารใหม่ (v0 ใหม่ + documentId ใหม่)** ผ่าน endpoint generate เดิม — เอกสารต้นทางและ version history ของมัน**ไม่ถูกแตะ** (ไม่เกิด version ใหม่) — template ที่ deactivate จะ re-generate ไม่ได้ตามกติกาของ FR-10
### FR-13: คำอธิบายต่อ version — commit message (เพิ่มเติม v5) ### FR-13: คำอธิบายต่อ version — commit message (เพิ่มเติม v5)
- Field ชื่อ `message` — string ทางเลือก (null ได้) ยาวไม่เกิน **2048 อักขระ** เก็บที่ `DocumentVersion.Message` — ผู้ใช้พิมพ์อธิบายการเปลี่ยนแปลงของ version นั้นแบบเดียวกับ git commit message - Field ชื่อ `message` — string ทางเลือก (null ได้) ยาวไม่เกิน **2048 อักขระ** เก็บที่ `DocumentVersion.Message` — ผู้ใช้พิมพ์อธิบายการเปลี่ยนแปลงของ version นั้นแบบเดียวกับ git commit message
@ -278,6 +281,17 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
--- ---
### FR-15: Version PDF — สำเนา PDF ที่ Word ผลิต (เพิ่มเติม v7)
- Word เป็นผู้ผลิต PDF เท่านั้น: add-in ใช้ `getFileAsync(Office.FileType.Pdf)` ทั้ง Save & Version และ Start Versioning; runtime guard `PdfFile 1.1`, ไม่บังคับใน manifest
- เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF; add-in เริ่ม v1 แล้ว max+1; ไม่ backfill หรือเปลี่ยนเลขข้อมูลเก่า
- capture docx แล้ว PDF ตามลำดับ ปิด File object เสมอ; PDF capture ไม่รองรับ/ล้ม/ว่าง → ข้าม PDF แล้วบันทึก docx ต่อ
- multipart `pdf` ทางเลือก: ต้องไม่ว่าง, นามสกุล .pdf, content-type application/pdf หรือ octet-stream หรือว่าง; ไม่ถูกต้อง → 400 ก่อน DB/storage, เกินขนาด → 413; request cap 100MB รวม docx+pdf
- เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable
- docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่
- upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow`
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
## 7. Data Model (Backend) ## 7. Data Model (Backend)
```csharp ```csharp
@ -294,8 +308,9 @@ public class DocumentVersion
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid DocumentId { get; set; } public Guid DocumentId { get; set; }
public int VersionNumber { get; set; } public int VersionNumber { get; set; } // generate=0; add-in=max+1 เริ่ม 1
public string ObjectKey { get; set; } // MinIO object key (bucket/path) public string ObjectKey { get; set; } // MinIO object key (bucket/path)
public string? PdfObjectKey { get; set; } // FR-15: null = ไม่มี PDF
public string ContentHash { get; set; } public string ContentHash { get; set; }
public string CreatedByUserId { get; set; } public string CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
@ -394,15 +409,17 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
9. ปล่อย session ทิ้งไว้จนกว่า access token หมดอายุ แล้วลองใช้งานต่อ → ต้อง silent-refresh สำเร็จโดยผู้ใช้ไม่รู้สึกสะดุด (ไม่ต้อง login ใหม่) 9. ปล่อย session ทิ้งไว้จนกว่า access token หมดอายุ แล้วลองใช้งานต่อ → ต้อง silent-refresh สำเร็จโดยผู้ใช้ไม่รู้สึกสะดุด (ไม่ต้อง login ใหม่)
10. ทดสอบบน Word for Mac: Custom XML Part อาจ fail แบบ silent ตามที่ระบุใน spec — ต้องมี fallback ที่ระบบยังทำงานถูกต้องแม้ XML part เขียนไม่สำเร็จ (เพราะ backend คือ source of truth) 10. ทดสอบบน Word for Mac: Custom XML Part อาจ fail แบบ silent ตามที่ระบุใน spec — ต้องมี fallback ที่ระบบยังทำงานถูกต้องแม้ XML part เขียนไม่สำเร็จ (เพราะ backend คือ source of truth)
11. (v2) upload template .docx ที่มี content controls ผ่านหน้าเว็บ → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้ 11. (v2) upload template .docx ที่มี content controls ผ่านหน้าเว็บ → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
12. (v2) กรอก form บนเว็บสร้างเอกสาร → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง formatting ไม่พัง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที โดยไม่ต้องกด Start Versioning 12. (v2) กรอก form บนเว็บสร้างเอกสาร → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง formatting ไม่พัง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 0 ทันที โดยไม่ต้องกด Start Versioning
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 แล้วสร้างเอกสารใหม่เป็น v0 โดย**เอกสารต้นทางไม่มี version ใหม่**
16. (v5) พิมพ์คำอธิบายตอนสร้าง version ได้จากทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า (web ตาราง + การ์ดมือถือ, add-in panel) · เว้นว่างได้ (ไม่ส่ง/ช่องว่างล้วน = ไม่มีข้อความ ไม่ error) 16. (v5) พิมพ์คำอธิบายตอนสร้าง version ได้จากทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า (web ตาราง + การ์ดมือถือ, add-in panel) · เว้นว่างได้ (ไม่ส่ง/ช่องว่างล้วน = ไม่มีข้อความ ไม่ error)
17. (v6) admin เลือก editor ของ field type `text` เป็น `textarea` ได้ผ่านหน้าจัดการ template → ฟอร์มสร้างเอกสารแสดงเป็น textarea · field `number`/`date` ใช้ control ตาม type (ช่องตัวเลข / date picker) · ส่ง editor ที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · ไม่ส่ง editor → ได้ default ของ type · ค่า field และการ generate ไม่เปลี่ยนจากเดิม 17. (v6) admin เลือก editor ของ field type `text` เป็น `textarea` ได้ผ่านหน้าจัดการ template → ฟอร์มสร้างเอกสารแสดงเป็น textarea · field `number`/`date` ใช้ control ตาม type (ช่องตัวเลข / date picker) · ส่ง editor ที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · ไม่ส่ง editor → ได้ default ของ type · ค่า field และการ generate ไม่เปลี่ยนจากเดิม
--- ---
18. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง
## 11. งานที่ Claude Code ควรทำ ## 11. งานที่ Claude Code ควรทำ
### Frontend ### Frontend
@ -426,7 +443,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
8. เขียน `docker-compose.yml` สำหรับ local dev: MinIO + Keycloak (import realm-export.json อัตโนมัติ) + DB container + backend 8. เขียน `docker-compose.yml` สำหรับ local dev: MinIO + Keycloak (import realm-export.json อัตโนมัติ) + DB container + backend
9. เขียน README อธิบาย local dev setup (sideload add-in, run backend, connection string, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก) 9. เขียน README อธิบาย local dev setup (sideload add-in, run backend, connection string, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก)
10. (v2) Implement `TemplatesController` + entities `Template`/`TemplateField`/`DocumentFieldValue` + migrations (FR-9) รวม parse SDT tags ตอน upload 10. (v2) Implement `TemplatesController` + entities `Template`/`TemplateField`/`DocumentFieldValue` + migrations (FR-9) รวม parse SDT tags ตอน upload
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 + v0 + 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) 14. (v5) เพิ่ม `DocumentVersion.Message` + migration `AddVersionMessage` + รับ/คืน `message` ทั้ง upload/generate/history + ตรวจความยาว 2048 (FR-13)
@ -441,3 +458,8 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
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) 7. (v5) ฟอร์มสร้างเอกสารเพิ่ม textarea ทางเลือก (commit message) + แสดง `message` ใน version history (FR-13)
8. (v6) หน้าแก้ field definitions เพิ่มตัวเลือก editor ต่อ field (เลือกได้เฉพาะ type `text`) + ฟอร์มสร้างเอกสาร render ตาม editor (textarea/ช่องตัวเลข/date picker/textbox) + util `fieldEditor` (FR-14) 8. (v6) หน้าแก้ field definitions เพิ่มตัวเลือก editor ต่อ field (เลือกได้เฉพาะ type `text`) + ฟอร์มสร้างเอกสาร render ตาม editor (textarea/ช่องตัวเลข/date picker/textbox) + util `fieldEditor` (FR-14)
### เพิ่มเติม v7 (FR-15)
- Add-in: PDF capture + multipart + warning + history PDF
- Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0
- Web: เปิด Word/PDF + fallback + history v0/v1+

View file

@ -0,0 +1,75 @@
import { onBeforeUnmount, ref } from 'vue'
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */
export function useVersionOpen() {
const { apiJson } = useApi()
const openingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fallbackUrl = ref<string | null>(null)
const openNotice = ref<string | null>(null)
let cleanup = () => {}
let generation = 0
let pendingTab: Window | null = null
function resetOpen() {
generation++
pendingTab?.close()
pendingTab = null
cleanup()
openingVersionId.value = null
downloadError.value = null
fallbackUrl.value = null
openNotice.value = null
}
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
if (openingVersionId.value) return
resetOpen()
const current = generation
openingVersionId.value = versionId
// Reserve a tab during the user gesture, before the authenticated request loses activation.
let tab: Window | null = null
try {
tab = format === 'pdf' ? window.open('about:blank', '_blank') : null
if (tab) tab.opener = null
pendingTab = tab
const link = await apiJson<{ url: string; expiresAt: string }>(versionDownloadPath(documentId, versionId, format))
if (current !== generation) { tab?.close(); return }
if (!link) throw new Error('ขอลิงก์เปิดไฟล์ไม่สำเร็จ')
fallbackUrl.value = link.url
if (format === 'pdf') {
if (tab && !tab.closed) {
tab.location.replace(link.url)
pendingTab = null
}
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
} else {
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(link.url))
}
} catch (err) {
tab?.close()
if (pendingTab === tab) pendingTab = null
if (current === generation) downloadError.value = err instanceof Error ? err.message : 'เปิดไฟล์ไม่สำเร็จ'
} finally {
if (current === generation) openingVersionId.value = null
}
}
onBeforeUnmount(resetOpen)
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
}

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น) /** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v1 (ไม่แตะเอกสารเดิม) */ * + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v0 (ไม่แตะเอกสารเดิม) */
import type { DocumentDetail, VersionListItem } from '~/types/api' import type { DocumentDetail, VersionListItem } from '~/types/api'
const route = useRoute() const route = useRoute()
const { apiJson, downloadFile } = useApi() const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion } = useVersionOpen()
const documentId = computed(() => String(route.params.documentId)) const documentId = computed(() => String(route.params.documentId))
@ -14,8 +15,6 @@ const loading = ref(true)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const expandedVersionId = ref<string | null>(null) const expandedVersionId = ref<string | null>(null)
const downloadingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? []) const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? [])
@ -52,19 +51,6 @@ function regenerate() {
navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`) navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`)
} }
async function download(v: VersionListItem) {
downloadingVersionId.value = v.versionId
downloadError.value = null
try {
const base = (detail.value?.fileName ?? 'document').replace(/\.docx$/i, '')
await downloadFile(`/api/documents/${documentId.value}/versions/${v.versionId}/download`, `${base}_v${v.versionNumber}.docx`)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloadingVersionId.value = null
}
}
onMounted(load) onMounted(load)
</script> </script>
@ -190,12 +176,14 @@ onMounted(load)
<div class="flex shrink-0 flex-col items-end gap-2"> <div class="flex shrink-0 flex-col items-end gap-2">
<button <button
type="button" type="button"
:disabled="downloadingVersionId === v.versionId" :disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs" class="btn btn-secondary btn-xs"
@click.stop="download(v)" @click.stop="openVersion(documentId, v.versionId)"
> >
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }} {{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button> </button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<button <button
type="button" type="button"
class="text-xs text-muted" class="text-xs text-muted"
@ -257,12 +245,14 @@ onMounted(load)
<td class="px-4 py-3 text-right whitespace-nowrap"> <td class="px-4 py-3 text-right whitespace-nowrap">
<button <button
type="button" type="button"
:disabled="downloadingVersionId === v.versionId" :disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs" class="btn btn-secondary btn-xs"
@click.stop="download(v)" @click.stop="openVersion(documentId, v.versionId)"
> >
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }} {{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button> </button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs ml-2"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<span class="ml-2 text-xs text-muted"> <span class="ml-2 text-xs text-muted">
{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }} {{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}
</span> </span>
@ -292,6 +282,11 @@ onMounted(load)
</table> </table>
</div> </div>
</template> </template>
<AlertBox v-if="fallbackUrl">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">เปิด / ดาวน์โหลดไฟล์สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดไฟล์อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox> <AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div> </div>
</template> </template>

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด /** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */ * FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api' import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute() const route = useRoute()
const { apiJson, downloadFile } = useApi() const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId)) const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null) const detail = ref<TemplateDetail | null>(null)
@ -23,8 +24,6 @@ const loadError = ref<string | null>(null)
const submitting = ref(false) const submitting = ref(false)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const result = ref<GenerateDocumentResponse | null>(null) const result = ref<GenerateDocumentResponse | null>(null)
const downloading = ref(false)
const downloadError = ref<string | null>(null)
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder)) const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
@ -98,22 +97,9 @@ async function submit() {
} }
} }
async function download() {
if (!result.value) return
downloading.value = true
downloadError.value = null
try {
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
await downloadFile(result.value.downloadUrl, name)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloading.value = false
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */ /** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
function resetForm() { function resetForm() {
resetOpen()
result.value = null result.value = null
submitError.value = null submitError.value = null
downloadError.value = null downloadError.value = null
@ -139,7 +125,7 @@ onMounted(load)
</AlertBox> </AlertBox>
<template v-else-if="detail"> <template v-else-if="detail">
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว --> <!-- ผลลัพธ์: สร้างสำเร็จเป็น v0 แล้ว -->
<template v-if="result"> <template v-if="result">
<AlertBox type="success"> <AlertBox type="success">
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
@ -165,13 +151,18 @@ onMounted(load)
</button> </button>
<button <button
type="button" type="button"
:disabled="downloading" :disabled="openingVersionId !== null"
class="btn btn-primary w-full sm:w-auto" class="btn btn-primary w-full sm:w-auto"
@click="download" @click="openVersion(result.documentId, result.versionId)"
> >
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }} {{ openingVersionId !== null ? 'กำลังเตรียมไฟล์…' : 'เปิดใน Word' }}
</button> </button>
</div> </div>
<AlertBox v-if="fallbackUrl" class="mt-4">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลด .docx สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดใน Word อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox> <AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
</div> </div>
@ -181,7 +172,7 @@ onMounted(load)
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li> <li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
<li> <li>
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning tracked document และแสดง <strong>version 0</strong> ทันที โดยไม่ต้องกด Start Versioning
</li> </li>
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save &amp; Version</strong> เพื่อบันทึก version ถัดไป</li> <li>แก้ไขเอกสารต่อแล้วกด <strong>Save &amp; Version</strong> เพื่อบันทึก version ถัดไป</li>
</ol> </ol>
@ -278,7 +269,7 @@ onMounted(load)
:disabled="submitting || sortedFields.length === 0 || !detail.isActive" :disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="btn btn-primary w-full sm:w-auto" class="btn btn-primary w-full sm:w-auto"
> >
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }} {{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
</button> </button>
</div> </div>
</form> </form>

View file

@ -97,6 +97,7 @@ export interface DocumentDetail {
/** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว /** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว
* (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */ * (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */
export interface VersionListItem { export interface VersionListItem {
hasPdf: boolean;
versionId: string versionId: string
versionNumber: number versionNumber: number
createdAt: string createdAt: string

View file

@ -0,0 +1,14 @@
export type VersionFormat = 'docx' | 'pdf'
export function versionFileName(fileName: string, versionNumber: number, format: VersionFormat = 'docx'): string {
return `${fileName.replace(/\.docx$/i, '')}_v${versionNumber}.${format}`
}
export function versionDownloadPath(documentId: string, versionId: string, format: VersionFormat = 'docx'): string {
return `/api/documents/${documentId}/versions/${versionId}/download-link${format === 'pdf' ? '?format=pdf' : ''}`
}
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
export function wordOpenUrl(presignedUrl: string): string {
return `ms-word:ofe|u|${presignedUrl}`
}

View file

@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, type App } from 'vue'
import { useVersionOpen } from '../app/composables/useVersionOpen'
let app: App
let host: HTMLDivElement
const apiJson = vi.fn()
const replace = vi.fn()
const close = vi.fn()
let tab: { opener: unknown; closed: boolean; location: { replace: typeof replace }; close: typeof close }
function mount() {
let result!: ReturnType<typeof useVersionOpen>
host = document.createElement('div')
document.body.append(host)
app = createApp({ setup() { result = useVersionOpen(); return () => null } })
app.mount(host)
return result
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('useApi', () => ({ apiJson }))
tab = { opener: {}, closed: false, location: { replace }, close }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
})
afterEach(() => {
app?.unmount()
host?.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
vi.useRealTimers()
})
describe('version open lifecycle', () => {
it('reserves PDF tab synchronously, navigates after auth, leaves completed PDF open on unmount', async () => {
let resolve!: (value: unknown) => void
apiJson.mockReturnValue(new Promise(r => { resolve = r }))
const state = mount()
const pending = state.openVersion('d', 'v', 'pdf')
expect(window.open).toHaveBeenCalledWith('about:blank', '_blank')
expect(tab.opener).toBeNull()
expect(state.openingVersionId.value).toBe('v')
resolve({ url: 'https://files.example/v.pdf' })
await pending
expect(replace).toHaveBeenCalledWith('https://files.example/v.pdf')
expect(state.fallbackUrl.value).toBe('https://files.example/v.pdf')
app.unmount()
expect(close).not.toHaveBeenCalled()
})
it('keeps a fallback when popup is blocked', async () => {
vi.mocked(window.open).mockReturnValue(null)
apiJson.mockResolvedValue({ url: 'https://files.example/v.pdf' })
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(state.openNotice.value).toBeTruthy()
expect(state.fallbackUrl.value).toBe('https://files.example/v.pdf')
})
it('closes pending tab immediately on reset and ignores a late response', async () => {
let resolve!: (value: unknown) => void
apiJson.mockReturnValue(new Promise(r => { resolve = r }))
const state = mount()
const pending = state.openVersion('d', 'v', 'pdf')
state.resetOpen()
expect(close).toHaveBeenCalledTimes(1)
resolve({ url: 'https://files.example/v.pdf' })
await pending
expect(replace).not.toHaveBeenCalled()
expect(state.fallbackUrl.value).toBeNull()
})
it('closes reserved tab and reports request failure', async () => {
apiJson.mockRejectedValue(new Error('offline'))
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(close).toHaveBeenCalled()
expect(state.downloadError.value).toBe('offline')
expect(state.openingVersionId.value).toBeNull()
})
it('handles synchronous popup errors and restores controls', async () => {
vi.mocked(window.open).mockImplementation(() => { throw new Error('popup error') })
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(state.downloadError.value).toBe('popup error')
expect(state.openingVersionId.value).toBeNull()
})
it('offers Word fallback after timeout and removes listeners on unmount', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const remove = vi.spyOn(window, 'removeEventListener')
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
const state = mount()
await state.openVersion('d', 'v')
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
vi.advanceTimersByTime(1500)
expect(state.openNotice.value).toBeTruthy()
expect(state.fallbackUrl.value).toBeTruthy()
app.unmount()
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
})
})

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { versionFileName, versionDownloadPath, wordOpenUrl } from '../app/utils/versionDownload'
describe('version downloads', () => {
it('names v0 and later artifacts', () => {
expect(versionFileName('คำร้อง.DOCX', 0)).toBe('คำร้อง_v0.docx')
expect(versionFileName('คำร้อง.docx', 1, 'pdf')).toBe('คำร้อง_v1.pdf')
})
it('requests the authenticated download-link format', () => {
expect(versionDownloadPath('d', 'v')).toBe('/api/documents/d/versions/v/download-link')
expect(versionDownloadPath('d', 'v', 'pdf')).toBe('/api/documents/d/versions/v/download-link?format=pdf')
})
it('preserves the signed URL exactly inside the Office URI', () => {
const url = 'https://files.example/d.docx?X-Amz-Signature=abc&response-content-disposition=attachment%3B%20filename%2A%3DUTF-8%27%27'
expect(wordOpenUrl(url)).toBe(`ms-word:ofe|u|${url}`)
})
})