From b3c1c667f7d53dfbe6d886abccd222875357b8f8 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 10:51:13 +0700 Subject: [PATCH] Phase 12: draft generation (FR-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ไฟล์ร่าง (draft) — generate docx จาก template แบบ stateless ไม่สร้าง Document/version; stamp เฉพาะ custom property TemplateId เพื่อให้ add-in เห็นเป็น untracked; Start Versioning ส่ง form field templateId ให้ backend ผูก template ตอน auto-create — เอกสารเริ่มที่ v1 (ไม่มี v0) + field sync เต็มระบบ - Backend: POST /api/templates/{id}/generate-draft (200 คืนไฟล์) + DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation ใช้ร่วมกับ generate + UploadVersion รับ templateId (non-GUID/unknown → 400, deactivated → ผูกได้, document เดิม → เฉย) + CORS expose Content-Disposition - Add-in: readTrackedProperties อ่าน TemplateId + ส่ง templateId เฉพาะ startVersioning + hint ไฟล์ร่างใน task pane - Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + downloadFile รับ init (POST → blob) - Docs: spec v8 (FR-16, AC-19) + Phase 12 + CONTEXT.md + ADR 0002 + acceptance-review AC-19 - Tests: backend 82 / add-in 66 / web 59 + build ครบ; QA reviewer PASS (2 rounds); ค้าง manual บน Word จริง Co-Authored-By: Claude Code --- CLAUDE.md | 12 +- CONTEXT.md | 1 + addin/src/services/metadataService.test.ts | 9 + addin/src/services/metadataService.ts | 14 +- addin/src/services/versionFlow.test.ts | 19 +- addin/src/services/versionFlow.ts | 14 +- addin/src/services/versioningService.test.ts | 18 ++ addin/src/services/versioningService.ts | 9 +- addin/src/taskpane/components/App.tsx | 38 ++-- .../AuthTests.cs | 1 + .../DraftTests.cs | 129 ++++++++++++++ .../TemplateLinkingTests.cs | 168 ++++++++++++++++++ .../TestDtos.cs | 8 +- .../Controllers/DocumentsController.cs | 58 ++---- .../Controllers/TemplatesController.cs | 68 +++++++ .../WordVersioning.Api/Models/TemplateDtos.cs | 9 + .../WordVersioning.Api/Models/VersionDtos.cs | 4 + backend/WordVersioning.Api/Program.cs | 5 +- .../Services/AddInMetadata.cs | 5 + .../Services/DocxGenerationService.cs | 72 ++++++-- .../Services/TemplateFieldValidation.cs | 38 ++++ docs/acceptance-review.md | 33 +++- docs/adr/0002-draft-stateless.md | 14 ++ docs/plans/fr16-draft-generation-plan.md | 4 +- docs/plans/officejs-implementation-plan.md | 17 +- specs/spec-officejs.md | 16 ++ web/app/composables/useApi.ts | 5 +- .../pages/documents/create/[templateId].vue | 59 +++++- web/test/useApi.spec.ts | 117 ++++++++++++ web/vitest.config.ts | 8 + 30 files changed, 879 insertions(+), 93 deletions(-) create mode 100644 backend/WordVersioning.Api.IntegrationTests/DraftTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/TemplateLinkingTests.cs create mode 100644 backend/WordVersioning.Api/Services/TemplateFieldValidation.cs create mode 100644 docs/adr/0002-draft-stateless.md create mode 100644 web/test/useApi.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index b8ced4b..13a47c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–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` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 82 / add-in 66 / web 59 + build ครบ), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน 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–15, acceptance criteria 1–18, data models, platform constraints) -- `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–18 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ +- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–16, acceptance criteria 1–19, data models, platform constraints) +- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 13 phases (0–12) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC +- `docs/acceptance-review.md` — ผลตรวจ AC 1–19 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan @@ -96,3 +96,7 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี เว็บ 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|` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข. + +## ไฟล์ร่าง (FR-16 / spec v8) + +ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` **stateless** (คืน docx ทันที ไม่สร้าง Document/version ไม่แตะ MinIO; ไม่มีปุ่มเปิดใน Word เพราะไม่มี URL) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทย. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`. diff --git a/CONTEXT.md b/CONTEXT.md index cc69c7b..d60c353 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,5 +2,6 @@ - **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort - **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word +- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template แบบ stateless — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1) - **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น - **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact diff --git a/addin/src/services/metadataService.test.ts b/addin/src/services/metadataService.test.ts index 954f5d4..f124de4 100644 --- a/addin/src/services/metadataService.test.ts +++ b/addin/src/services/metadataService.test.ts @@ -40,6 +40,15 @@ describe("readTrackedProperties", () => { expect(properties.lastVersionId).toBeUndefined(); expect(properties.lastVersionTimestamp).toBeUndefined(); }); + + it("FR-16 — ไฟล์ร่าง: อ่าน TemplateId ได้โดยไม่มี properties ของ tracked document", async () => { + installWordMock({ properties: { TemplateId: "33333333-3333-3333-3333-333333333333" } }); + + const properties = await readTrackedProperties(); + expect(properties.templateId).toBe("33333333-3333-3333-3333-333333333333"); + expect(properties.savedByAddIn).toBeUndefined(); + expect(properties.documentTrackingId).toBeUndefined(); + }); }); describe("isTrackedDocument (FR-1)", () => { diff --git a/addin/src/services/metadataService.ts b/addin/src/services/metadataService.ts index 2cda6b2..c0b263d 100644 --- a/addin/src/services/metadataService.ts +++ b/addin/src/services/metadataService.ts @@ -19,6 +19,8 @@ export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn"; export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId"; export const LAST_VERSION_ID_PROPERTY = "LastVersionId"; export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp"; +/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ +export const TEMPLATE_ID_PROPERTY = "TemplateId"; /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ export interface TrackedDocumentProperties { @@ -26,6 +28,8 @@ export interface TrackedDocumentProperties { documentTrackingId?: string; lastVersionId?: string; lastVersionTimestamp?: string; + /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ + templateId?: string; } /** item ใน Custom XML Part `` */ @@ -36,7 +40,7 @@ export interface XmlVersionEntry { user: string; } -/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */ +/** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */ export async function readTrackedProperties(): Promise { return Word.run(async (context) => { const properties = context.document.properties.customProperties; @@ -44,11 +48,13 @@ export async function readTrackedProperties(): Promise @@ -61,11 +67,15 @@ export async function readTrackedProperties(): Promise { const properties = await readTrackedProperties(); return properties.savedByAddIn === ADDIN_SIGNATURE_GUID; diff --git a/addin/src/services/versionFlow.test.ts b/addin/src/services/versionFlow.test.ts index 8f30e09..33777f9 100644 --- a/addin/src/services/versionFlow.test.ts +++ b/addin/src/services/versionFlow.test.ts @@ -141,6 +141,22 @@ describe("startVersioning (FR-1 / AC-5)", () => { const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก"); + // ไม่ใช่ไฟล์ร่าง → ไม่แนบ templateId + expect(form.get("templateId")).toBeNull(); + }); + + it("FR-16 — ไฟล์ร่าง: แนบ templateId ไปกับ form ของ v1", async () => { + installWordMock({}); + installOfficeDocumentMock(undefined); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } }) + ); + + await startVersioning("เริ่มติดตามฉบับแรก", "44444444-4444-4444-4444-444444444444"); + + const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); + expect(String(form.get("templateId"))).toBe("44444444-4444-4444-4444-444444444444"); }); }); @@ -187,11 +203,12 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => { expect(xml).toContain('number="1"'); expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"')); - // upload ไปยัง document เดิม + fields ล่าสุด + // upload ไปยัง document เดิม + fields ล่าสุด (ไม่ส่ง templateId — FR-16 ใช้เฉพาะ Start Versioning) const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions"); expect(String(form.get("clientVersionId"))).toBe(newLastVersionId); expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" }); + expect(form.get("templateId")).toBeNull(); }); it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => { diff --git a/addin/src/services/versionFlow.ts b/addin/src/services/versionFlow.ts index e61009a..2f5e93a 100644 --- a/addin/src/services/versionFlow.ts +++ b/addin/src/services/versionFlow.ts @@ -74,7 +74,8 @@ async function uploadCurrentFile( clientVersionId: string, fields: Record, message?: string, - onProgress?: (progress: string) => void + onProgress?: (progress: string) => void, + templateId?: string ): Promise { const file = await getCurrentDocumentFile(); let pdf: Blob | undefined; @@ -94,14 +95,17 @@ async function uploadCurrentFile( fields, guessDocumentFileName(), message, - pdf + pdf, + templateId ); } /** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 - * (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน) */ + * (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน) + * (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */ export async function startVersioning( message?: string, + templateId?: string, onProgress?: (progress: string) => void ): Promise { const documentId = generateGuid(); @@ -128,9 +132,9 @@ export async function startVersioning( onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); const fields = await readContentControlFields(); - // 4) upload version แรก + // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16) onProgress?.("กำลังอัปโหลด version 1…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress); + const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId); return { documentId, ...result }; } diff --git a/addin/src/services/versioningService.test.ts b/addin/src/services/versioningService.test.ts index 4e5f49f..a15ff2c 100644 --- a/addin/src/services/versioningService.test.ts +++ b/addin/src/services/versioningService.test.ts @@ -30,4 +30,22 @@ it("uploads optional PDF alongside the docx in one request", async () => { 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"); + expect(form.get("templateId")).toBeNull(); +}); + +it("FR-16 — appends optional templateId form field (Start Versioning ของไฟล์ร่าง)", async () => { + await uploadVersion( + "d", + new Blob(["docx"]), + "v", + undefined, + undefined, + undefined, + undefined, + "44444444-4444-4444-4444-444444444444" + ); + // mock แชร์กันทั้งไฟล์ — เอา call ล่าสุด (ของ test นี้เอง) + const calls = (apiSendExpectJson as jest.Mock).mock.calls; + const form = calls[calls.length - 1][1].body as FormData; + expect(String(form.get("templateId"))).toBe("44444444-4444-4444-4444-444444444444"); }); diff --git a/addin/src/services/versioningService.ts b/addin/src/services/versioningService.ts index be94482..9627cc5 100644 --- a/addin/src/services/versioningService.ts +++ b/addin/src/services/versioningService.ts @@ -9,7 +9,8 @@ import { generateGuid } from "./metadataService"; */ /** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) - * + FR-13: แนบ commit message ทางเลือกของผู้ใช้ */ + * + FR-13: แนบ commit message ทางเลือกของผู้ใช้ + * + FR-16: แนบ templateId เฉพาะตอน Start Versioning ของไฟล์ร่าง (backend ใช้ตอน auto-create Document) */ export async function uploadVersion( documentId: string, file: Blob, @@ -17,7 +18,8 @@ export async function uploadVersion( fields?: Record, fileName?: string, message?: string, - pdf?: Blob + pdf?: Blob, + templateId?: string ): Promise { const form = new FormData(); form.append("file", file, fileName || `${documentId}.docx`); @@ -30,6 +32,9 @@ export async function uploadVersion( if (trimmedMessage) { form.append("message", trimmedMessage); } + if (templateId) { + form.append("templateId", templateId); + } return apiSendExpectJson( `/api/documents/${documentId}/versions`, { method: "POST", body: form }, diff --git a/addin/src/taskpane/components/App.tsx b/addin/src/taskpane/components/App.tsx index 690a5a2..d2a6da3 100644 --- a/addin/src/taskpane/components/App.tsx +++ b/addin/src/taskpane/components/App.tsx @@ -76,6 +76,8 @@ const App: React.FC = () => { const [username, setUsername] = React.useState(null); const [documentId, setDocumentId] = React.useState(null); + /** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */ + const [documentTemplateId, setDocumentTemplateId] = React.useState(null); const [versions, setVersions] = React.useState([]); const [historyLoading, setHistoryLoading] = React.useState(false); const [historyError, setHistoryError] = React.useState(null); @@ -101,6 +103,7 @@ const App: React.FC = () => { if (error instanceof AuthRequiredError) { setPhase("signed-out"); setDocumentId(null); + setDocumentTemplateId(null); setVersions([]); setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); return true; @@ -125,7 +128,9 @@ const App: React.FC = () => { [handleAuthRequired] ); - /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */ + /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history + * FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked) + * ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย */ const detect = React.useCallback(async () => { setPhase("checking"); setBootError(null); @@ -138,19 +143,17 @@ const App: React.FC = () => { authService.startAutoRefresh(); setUsername(authService.getUsername() ?? null); - const tracked = await metadataService.isTrackedDocument(); - if (!tracked) { - setPhase("untracked"); - return; - } const properties = await metadataService.readTrackedProperties(); - const id = properties.documentTrackingId; + const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID; + const id = tracked ? properties.documentTrackingId : undefined; if (!id) { - // mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked + // ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16) + setDocumentTemplateId(properties.templateId ?? null); setPhase("untracked"); return; } setDocumentId(id); + setDocumentTemplateId(null); setPhase("tracking"); await refreshHistory(id); } catch (error) { @@ -207,20 +210,23 @@ const App: React.FC = () => { authService.signOut(); setUsername(null); setDocumentId(null); + setDocumentTemplateId(null); setVersions([]); setLoginError(null); setPhase("signed-out"); }; - /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) */ + /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) + * FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */ const handleStartVersioning = async (message: string) => { setStarting(true); setFlowError(null); setFlowSuccess(null); setFlowWarning(null); try { - const result = await startVersioning(message, setProgressMessage); + const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage); setDocumentId(result.documentId); + setDocumentTemplateId(null); setPhase("tracking"); setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); if (!result.hasPdf) @@ -361,6 +367,12 @@ const App: React.FC = () => { กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ) + {documentTemplateId ? ( + + ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง + และ sync ค่า fields ให้อัตโนมัติ + + ) : null}