Phase 12: draft generation (FR-16)

ไฟล์ร่าง (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 <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 10:51:13 +07:00
parent 0edb70fd38
commit b3c1c667f7
30 changed files with 879 additions and 93 deletions

View file

@ -14,13 +14,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–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) - `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 แบ่ง 12 phases (0–11) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC - `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–18 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ - `docs/acceptance-review.md` — ผลตรวจ AC 1–19 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan เมื่อ 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. เว็บ 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 หรือเปลี่ยนเลข. `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 หรือเปลี่ยนเลข.
## ไฟล์ร่าง (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`.

View file

@ -2,5 +2,6 @@
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort - **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word - **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template แบบ stateless — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1)
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น - **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact - **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact

View file

@ -40,6 +40,15 @@ describe("readTrackedProperties", () => {
expect(properties.lastVersionId).toBeUndefined(); expect(properties.lastVersionId).toBeUndefined();
expect(properties.lastVersionTimestamp).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)", () => { describe("isTrackedDocument (FR-1)", () => {

View file

@ -19,6 +19,8 @@ export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId"; export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
export const LAST_VERSION_ID_PROPERTY = "LastVersionId"; export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp"; export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties { export interface TrackedDocumentProperties {
@ -26,6 +28,8 @@ export interface TrackedDocumentProperties {
documentTrackingId?: string; documentTrackingId?: string;
lastVersionId?: string; lastVersionId?: string;
lastVersionTimestamp?: string; lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string;
} }
/** item ใน Custom XML Part `<VersionHistory>` */ /** item ใน Custom XML Part `<VersionHistory>` */
@ -36,7 +40,7 @@ export interface XmlVersionEntry {
user: string; user: string;
} }
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */ /** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> { export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => { return Word.run(async (context) => {
const properties = context.document.properties.customProperties; const properties = context.document.properties.customProperties;
@ -44,11 +48,13 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY); const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY); const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY); const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync // load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value"); savedByAddIn.load("value");
documentTrackingId.load("value"); documentTrackingId.load("value");
lastVersionId.load("value"); lastVersionId.load("value");
lastVersionTimestamp.load("value"); lastVersionTimestamp.load("value");
templateId.load("value");
await context.sync(); await context.sync();
const read = (property: Word.CustomProperty): string | undefined => const read = (property: Word.CustomProperty): string | undefined =>
@ -61,11 +67,15 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
documentTrackingId: read(documentTrackingId), documentTrackingId: read(documentTrackingId),
lastVersionId: read(lastVersionId), lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp), lastVersionTimestamp: read(lastVersionTimestamp),
templateId: read(templateId),
}; };
}); });
} }
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) */ /** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
* หมายเหตุ: App.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
export async function isTrackedDocument(): Promise<boolean> { export async function isTrackedDocument(): Promise<boolean> {
const properties = await readTrackedProperties(); const properties = await readTrackedProperties();
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID; return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;

View file

@ -141,6 +141,22 @@ describe("startVersioning (FR-1 / AC-5)", () => {
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก"); 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).toContain('number="1"');
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('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 }]); 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(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId); expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" }); expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
expect(form.get("templateId")).toBeNull();
}); });
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => { it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {

View file

@ -74,7 +74,8 @@ async function uploadCurrentFile(
clientVersionId: string, clientVersionId: string,
fields: Record<string, string>, fields: Record<string, string>,
message?: string, message?: string,
onProgress?: (progress: string) => void onProgress?: (progress: string) => void,
templateId?: string
): Promise<SaveAndVersionResult> { ): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile(); const file = await getCurrentDocumentFile();
let pdf: Blob | undefined; let pdf: Blob | undefined;
@ -94,14 +95,17 @@ async function uploadCurrentFile(
fields, fields,
guessDocumentFileName(), guessDocumentFileName(),
message, message,
pdf pdf,
templateId
); );
} }
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 /** 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( export async function startVersioning(
message?: string, message?: string,
templateId?: string,
onProgress?: (progress: string) => void onProgress?: (progress: string) => void
): Promise<StartVersioningResult> { ): Promise<StartVersioningResult> {
const documentId = generateGuid(); const documentId = generateGuid();
@ -128,9 +132,9 @@ export async function startVersioning(
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields(); const fields = await readContentControlFields();
// 4) upload version แรก // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
onProgress?.("กำลังอัปโหลด version 1…"); 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 }; return { documentId, ...result };
} }

View file

@ -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).name).toBe("d.pdf");
expect((form.get("pdf") as File).type).toBe("application/pdf"); expect((form.get("pdf") as File).type).toBe("application/pdf");
expect((form.get("file") as File).name).toBe("d.docx"); 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");
}); });

View file

@ -9,7 +9,8 @@ import { generateGuid } from "./metadataService";
*/ */
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) /** 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( export async function uploadVersion(
documentId: string, documentId: string,
file: Blob, file: Blob,
@ -17,7 +18,8 @@ export async function uploadVersion(
fields?: Record<string, string>, fields?: Record<string, string>,
fileName?: string, fileName?: string,
message?: string, message?: string,
pdf?: Blob pdf?: Blob,
templateId?: string
): Promise<UploadVersionResult> { ): Promise<UploadVersionResult> {
const form = new FormData(); const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`); form.append("file", file, fileName || `${documentId}.docx`);
@ -30,6 +32,9 @@ export async function uploadVersion(
if (trimmedMessage) { if (trimmedMessage) {
form.append("message", trimmedMessage); form.append("message", trimmedMessage);
} }
if (templateId) {
form.append("templateId", templateId);
}
return apiSendExpectJson<UploadVersionResult>( return apiSendExpectJson<UploadVersionResult>(
`/api/documents/${documentId}/versions`, `/api/documents/${documentId}/versions`,
{ method: "POST", body: form }, { method: "POST", body: form },

View file

@ -76,6 +76,8 @@ const App: React.FC = () => {
const [username, setUsername] = React.useState<string | null>(null); const [username, setUsername] = React.useState<string | null>(null);
const [documentId, setDocumentId] = React.useState<string | null>(null); const [documentId, setDocumentId] = React.useState<string | null>(null);
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
const [documentTemplateId, setDocumentTemplateId] = React.useState<string | null>(null);
const [versions, setVersions] = React.useState<VersionRecord[]>([]); const [versions, setVersions] = React.useState<VersionRecord[]>([]);
const [historyLoading, setHistoryLoading] = React.useState(false); const [historyLoading, setHistoryLoading] = React.useState(false);
const [historyError, setHistoryError] = React.useState<string | null>(null); const [historyError, setHistoryError] = React.useState<string | null>(null);
@ -101,6 +103,7 @@ const App: React.FC = () => {
if (error instanceof AuthRequiredError) { if (error instanceof AuthRequiredError) {
setPhase("signed-out"); setPhase("signed-out");
setDocumentId(null); setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]); setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
return true; return true;
@ -125,7 +128,9 @@ const App: React.FC = () => {
[handleAuthRequired] [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 () => { const detect = React.useCallback(async () => {
setPhase("checking"); setPhase("checking");
setBootError(null); setBootError(null);
@ -138,19 +143,17 @@ const App: React.FC = () => {
authService.startAutoRefresh(); authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null); setUsername(authService.getUsername() ?? null);
const tracked = await metadataService.isTrackedDocument();
if (!tracked) {
setPhase("untracked");
return;
}
const properties = await metadataService.readTrackedProperties(); 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) { if (!id) {
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked // ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
setDocumentTemplateId(properties.templateId ?? null);
setPhase("untracked"); setPhase("untracked");
return; return;
} }
setDocumentId(id); setDocumentId(id);
setDocumentTemplateId(null);
setPhase("tracking"); setPhase("tracking");
await refreshHistory(id); await refreshHistory(id);
} catch (error) { } catch (error) {
@ -207,20 +210,23 @@ const App: React.FC = () => {
authService.signOut(); authService.signOut();
setUsername(null); setUsername(null);
setDocumentId(null); setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]); setVersions([]);
setLoginError(null); setLoginError(null);
setPhase("signed-out"); 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) => { const handleStartVersioning = async (message: string) => {
setStarting(true); setStarting(true);
setFlowError(null); setFlowError(null);
setFlowSuccess(null); setFlowSuccess(null);
setFlowWarning(null); setFlowWarning(null);
try { try {
const result = await startVersioning(message, setProgressMessage); const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
setDocumentId(result.documentId); setDocumentId(result.documentId);
setDocumentTemplateId(null);
setPhase("tracking"); setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf) if (!result.hasPdf)
@ -361,6 +367,12 @@ const App: React.FC = () => {
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ) (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</Text> </Text>
{documentTemplateId ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
และ sync ค่า fields ให้อัตโนมัติ
</Text>
) : null}
<Button <Button
appearance="primary" appearance="primary"
icon={<DocumentArrowUp24Regular />} icon={<DocumentArrowUp24Regular />}
@ -454,7 +466,11 @@ const App: React.FC = () => {
open={startConfirmOpen} open={startConfirmOpen}
busy={starting} busy={starting}
title="เริ่มติดตามเอกสารนี้?" title="เริ่มติดตามเอกสารนี้?"
description="ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)" confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => void handleStartVersioning(message)} onConfirm={(message) => void handleStartVersioning(message)}
onDismiss={() => setStartConfirmOpen(false)} onDismiss={() => setStartConfirmOpen(false)}

View file

@ -10,6 +10,7 @@ public sealed class AuthTests(IntegrationTestFixture fixture)
[InlineData("GET", "api/templates")] [InlineData("GET", "api/templates")]
[InlineData("POST", "api/templates")] [InlineData("POST", "api/templates")]
[InlineData("GET", "api/templates/{0}/documents")] [InlineData("GET", "api/templates/{0}/documents")]
[InlineData("POST", "api/templates/{0}/generate-draft")]
[InlineData("POST", "api/documents/generate")] [InlineData("POST", "api/documents/generate")]
[InlineData("GET", "api/documents/{0}/versions")] [InlineData("GET", "api/documents/{0}/versions")]
[InlineData("POST", "api/documents/{0}/versions")] [InlineData("POST", "api/documents/{0}/versions")]

View file

@ -0,0 +1,129 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class DraftTests(IntegrationTestFixture fixture)
{
[Fact]
public async Task Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft หลัก");
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft",
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง
// คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment)
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
var fileBytes = await response.Content.ReadAsByteArrayAsync();
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(fileBytes, "position"));
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
// (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold)
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
Assert.True(runWithText.Bold);
// (3) stamp เฉพาะ TemplateId — ไม่มี SavedByAddIn/DocumentTrackingId เพื่อให้ add-in เห็นเป็น untracked (FR-16)
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
Assert.False(await db.DocumentVersions.AsNoTracking()
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
Assert.False(await db.DocumentFieldValues.AsNoTracking()
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
}
[Fact]
public async Task Draft_MissingRequiredField_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft required ขาด");
var values = new Dictionary<string, string>(TestFlow.DefaultValues);
values.Remove("employee_name"); // required field หาย
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadAsStringAsync();
Assert.Contains("employee_name", error);
}
[Theory]
[InlineData("salary", "ไม่ใช่เลข")]
[InlineData("start_date", "ไม่ใช่วันที่")]
public async Task Draft_WrongFieldType_Returns400(string key, string badValue)
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"draft type ผิด {key}");
var values = new Dictionary<string, string>(TestFlow.DefaultValues) { [key] = badValue };
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Draft_UnknownTemplate_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.PostAsJsonAsync(
$"api/templates/{Guid.NewGuid()}/generate-draft",
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Draft_DeactivatedTemplate_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft deactivate");
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft",
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Draft_IgnoresUndefinedValues()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft ค่าเกินนิยาม");
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
{
["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore
};
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var fileBytes = await response.Content.ReadAsByteArrayAsync();
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
}
}

View file

@ -0,0 +1,168 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-16 — ผูก template ตอน Start Versioning: form field `templateId` ทางเลือกของ upload
/// ใช้เฉพาะตอน auto-create Document · upload แรกของ Document ใหม่ = version 1 เสมอ + field sync เต็มระบบ</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class TemplateLinkingTests(IntegrationTestFixture fixture)
{
private static string DefaultFieldsJson() => JsonSerializer.Serialize(TestFlow.DefaultValues);
[Fact]
public async Task Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking หลัก");
// Start Versioning ของไฟล์ร่าง: upload แรกพร้อม templateId + fields ที่ extract จาก content controls
var documentId = Guid.NewGuid();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildTemplateDocx(), "ไฟล์ร่างทดสอบ.docx", Guid.NewGuid().ToString(),
fieldsJson: DefaultFieldsJson(), templateId: templateId.ToString()));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
var v1 = (await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
// upload แรกของ Document ใหม่ = version 1 เสมอ (ไม่มี v0 ใน flow ไฟล์ร่าง)
Assert.Equal(1, v1.VersionNumber);
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking()
.Include(d => d.Versions)
.SingleAsync(d => d.Id == documentId);
Assert.Equal(templateId, document.TemplateId); // ผูก template ต้นทาง
Assert.Equal(string.Empty, document.AddInSignature); // ฝั่ง add-in stamp ในไฟล์เอง — ค่าเดิมของ auto-create
var version = Assert.Single(document.Versions);
Assert.Equal(1, version.VersionNumber);
Assert.NotNull(version.FieldsSnapshotJson); // snapshot แบบมีนิยาม (ไม่ใช่ raw map)
// field sync เต็มระบบ (FR-11): DocumentFieldValue ครบทุก field ของ template
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey);
Assert.Equal(4, fieldValues.Count);
Assert.Equal("สมหญิง รักงาน", fieldValues["employee_name"].Value);
Assert.Equal("75000", fieldValues["salary"].Value);
Assert.All(fieldValues.Values, v => Assert.Equal(v1.VersionId, v.SourceVersionId));
Assert.All(fieldValues.Values, v => Assert.Equal(fixture.TestUserId, v.UpdatedByUserId));
// Save & Version ครั้งถัดไป (ไม่ส่ง templateId) = v2 และ TemplateId คงเดิม
using var second = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildTemplateDocx(), "ไฟล์ร่างทดสอบ.docx", Guid.NewGuid().ToString(),
fieldsJson: DefaultFieldsJson(), message: "แก้ไขรอบสอง"));
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
var v2 = (await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
Assert.Equal(2, v2.VersionNumber);
await using var db2 = fixture.CreateDb();
var docAfter = await db2.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
Assert.Equal(templateId, docAfter.TemplateId);
}
[Fact]
public async Task Upload_TemplateIdNotGuid_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking ไม่ใช่ GUID");
using var response = await client.PostAsync(
$"api/documents/{Guid.NewGuid()}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "a.docx", Guid.NewGuid().ToString(), templateId: "not-a-guid"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadAsStringAsync();
Assert.Contains("templateId", error);
}
[Fact]
public async Task Upload_TemplateIdUnknown_Returns400()
{
using var client = fixture.CreateClient();
_ = await TestFlow.CreateConfiguredTemplateAsync(client, "linking unknown"); // ตั้ง field defs ไว้ก่อน (ชุดเดียวกับที่ใช้จริง)
using var response = await client.PostAsync(
$"api/documents/{Guid.NewGuid()}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "a.docx", Guid.NewGuid().ToString(),
templateId: Guid.NewGuid().ToString()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Upload_TemplateIdDeactivated_StillLinks()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking deactivate");
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
// ไฟล์ร่างถูกสร้างตอน template ยัง active — deactivate หลังจากนั้นต้องไม่ขวางการเริ่ม track (นโยบาย FR-9/FR-12)
var documentId = Guid.NewGuid();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildTemplateDocx(), "ไฟล์ร่าง.docx", Guid.NewGuid().ToString(),
fieldsJson: DefaultFieldsJson(), templateId: templateId.ToString()));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
Assert.Equal(templateId, document.TemplateId);
}
[Fact]
public async Task Upload_ExistingDocument_IgnoresTemplateId()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking เฉย");
// upload แรกแบบ standalone (ไม่มี templateId) → Document ไม่ผูก template
var documentId = Guid.NewGuid();
using var first = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString()));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
// upload ที่สองส่ง templateId มาด้วย — document มีอยู่แล้วจึงเฉย (TemplateId คง null)
using var second = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString(), templateId: templateId.ToString()));
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
var v2 = (await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
Assert.Equal(2, v2.VersionNumber);
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
Assert.Null(document.TemplateId);
}
[Fact]
public async Task Upload_ExistingDocument_BadTemplateId_Returns400()
{
using var client = fixture.CreateClient();
_ = await TestFlow.CreateConfiguredTemplateAsync(client, "linking เฉย bad id");
var documentId = Guid.NewGuid();
using var first = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString()));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
// validate ทุกครั้งที่ส่งมา — แม้ document มีอยู่แล้วและค่าจะไม่ถูกใช้ก็ตาม
using var second = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString(), templateId: "not-a-guid"));
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
}
}

View file

@ -31,9 +31,11 @@ public record DocDetail(Guid Id, string FileName, DateTimeOffset CreatedAt, Guid
public static class ApiExtensions public static class ApiExtensions
{ {
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)</summary> /// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)
/// + templateId (option — FR-16 ไฟล์ร่าง: ส่งเฉพาะตอน Start Versioning)</summary>
public static MultipartFormDataContent VersionForm( public static MultipartFormDataContent VersionForm(
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null, byte[]? pdf = null) byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null,
byte[]? pdf = null, string? templateId = null)
{ {
var form = new MultipartFormDataContent(); var form = new MultipartFormDataContent();
var file = new ByteArrayContent(docx); var file = new ByteArrayContent(docx);
@ -50,6 +52,8 @@ public static class ApiExtensions
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(part, "pdf", "document.pdf"); form.Add(part, "pdf", "document.pdf");
} }
if (templateId is not null)
form.Add(new StringContent(templateId), "templateId");
return form; return form;
} }

View file

@ -1,4 +1,3 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Amazon.Runtime; using Amazon.Runtime;
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
@ -52,6 +51,18 @@ public class DocumentsController(
return BadRequest("ต้องแนบไฟล์ `file` (docx)"); return BadRequest("ต้องแนบไฟล์ `file` (docx)");
if (!Guid.TryParse(request.ClientVersionId, out _)) if (!Guid.TryParse(request.ClientVersionId, out _))
return BadRequest("`clientVersionId` ต้องเป็น GUID"); return BadRequest("`clientVersionId` ต้องเป็น GUID");
// FR-16 — templateId ทางเลือก (ส่งจาก Start Versioning ของไฟล์ร่าง): validate ทุกครั้งที่ส่งมา
// (ใช้จริงเฉพาะตอน auto-create Document ด้านล่าง — document มีอยู่แล้วจะถูกเฉย)
Guid? templateIdToLink = null;
if (!string.IsNullOrWhiteSpace(request.TemplateId))
{
if (!Guid.TryParse(request.TemplateId, out var parsedTemplateId))
return BadRequest("`templateId` ต้องเป็น GUID");
if (!await db.Templates.AsNoTracking().AnyAsync(t => t.Id == parsedTemplateId, ct))
return BadRequest($"ไม่พบ template {parsedTemplateId}");
templateIdToLink = parsedTemplateId; // template ที่ deactivate ผูกได้ — ไฟล์ร่างถูกสร้างตอน template ยัง active (นโยบายเดียวกับ FR-9/FR-12)
}
if (request.File.Length > MaxUploadBytes) if (request.File.Length > MaxUploadBytes)
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB");
if (!DocxUploadValidation.IsValidDocx(request.File)) if (!DocxUploadValidation.IsValidDocx(request.File))
@ -97,7 +108,7 @@ public class DocumentsController(
var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct); var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct);
uploadStream.Seek(0, SeekOrigin.Begin); uploadStream.Seek(0, SeekOrigin.Begin);
// สร้าง Document ถ้ายังไม่มี (FR-5) // สร้าง Document ถ้ายังไม่มี (FR-5) — ไฟล์ร่าง (FR-16) ผูก template ต้นทางจาก templateId ที่ส่งมา
var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct); var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct);
var createdDoc = false; var createdDoc = false;
if (doc is null) if (doc is null)
@ -107,6 +118,7 @@ public class DocumentsController(
Id = documentId, Id = documentId,
FileName = fileName, FileName = fileName,
AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง
TemplateId = templateIdToLink, // null = เอกสาร track เอง · มีค่า = ไฟล์ร่างจาก template (field sync ทำงานเต็มระบบที่ v1)
CreatedAt = now, CreatedAt = now,
}; };
db.Documents.Add(doc); db.Documents.Add(doc);
@ -257,32 +269,10 @@ public class DocumentsController(
if (message is { Length: > MaxVersionMessageLength }) if (message is { Length: > MaxVersionMessageLength })
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ"); return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
// validate required + type (FR-10) — field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11 // validate required + type (FR-10) — ชุดเดียวกับ generate-draft (FR-16)
var missingRequired = fieldDefs var validationError = TemplateFieldValidation.Validate(fieldDefs, values);
.Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty))) if (validationError is not null)
.Select(f => f.Key) return BadRequest(validationError);
.ToList();
if (missingRequired.Count > 0)
return BadRequest($"กรอก required fields ให้ครบ: {string.Join(", ", missingRequired)}");
var typeErrors = new List<string>();
foreach (var def in fieldDefs)
{
if (!values.TryGetValue(def.Key, out var v) || string.IsNullOrWhiteSpace(v))
continue;
var valid = def.Type switch
{
"number" => decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
"date" => DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out _),
_ => true,
};
if (!valid)
typeErrors.Add(def.Type == "number"
? $"`{def.Key}` ต้องเป็นตัวเลข"
: $"`{def.Key}` ต้องเป็นวันที่ที่ parse ได้ (ISO-8601 เช่น 2026-08-19)");
}
if (typeErrors.Count > 0)
return BadRequest(string.Join("; ", typeErrors));
// โหลด template จาก MinIO → generate ไฟล์ + stamp custom properties (FR-10) // โหลด template จาก MinIO → generate ไฟล์ + stamp custom properties (FR-10)
Stream templateStream; Stream templateStream;
@ -326,7 +316,7 @@ public class DocumentsController(
var doc = new Document var doc = new Document
{ {
Id = documentId, Id = documentId,
FileName = BuildGeneratedFileName(template.Name, now), FileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now),
AddInSignature = AddInMetadata.AddInSignatureGuid, AddInSignature = AddInMetadata.AddInSignatureGuid,
TemplateId = template.Id, TemplateId = template.Id,
CreatedAt = now, CreatedAt = now,
@ -578,16 +568,6 @@ public class DocumentsController(
} }
} }
/// <summary>ชื่อไฟล์ของเอกสารที่ generate: ชื่อ template + timestamp — ตัดอักขระที่ filename ใช้ไม่ได้ออก</summary>
private static string BuildGeneratedFileName(string templateName, DateTimeOffset now)
{
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
if (clean.Length == 0)
clean = "document";
return $"{clean}_{now:yyyyMMdd-HHmmss}.docx";
}
private static object? ParseSnapshot(string? json) private static object? ParseSnapshot(string? json)
{ {
if (json is null) if (json is null)

View file

@ -305,6 +305,74 @@ public class TemplatesController(
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags)); return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
} }
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง
/// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที
/// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้)</summary>
[HttpPost("{id:guid}/generate-draft")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
public async Task<IActionResult> GenerateDraft(Guid id, [FromBody] GenerateDraftRequest request, CancellationToken ct)
{
var template = await db.Templates.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == id, ct);
if (template is null)
return NotFound($"ไม่พบ template {id}");
if (!template.IsActive)
return BadRequest("template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)");
var fieldDefs = await db.TemplateFields.AsNoTracking()
.Where(f => f.TemplateId == template.Id)
.OrderBy(f => f.SortOrder)
.ToListAsync(ct);
if (!FieldMapJson.TryNormalize(request.Values, out var values, out var valuesError))
return BadRequest($"`values` ผิด format: {valuesError}");
// validate required + type ชุดเดียวกับ generate (FR-10) — ไฟล์ร่างไม่มี message เพราะไม่เกิด version
var validationError = TemplateFieldValidation.Validate(fieldDefs, values);
if (validationError is not null)
return BadRequest(validationError);
Stream templateStream;
try
{
templateStream = await storage.GetStreamAsync(minioOptions.Value.TemplatesBucket, template.ObjectKey, ct);
}
catch (KeyNotFoundException)
{
logger.LogError("Template object missing in storage templateId={TemplateId} key={ObjectKey}",
template.Id, template.ObjectKey);
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template ไม่พบใน storage");
}
byte[] fileBytes;
try
{
await using (templateStream)
{
// null (JSON null) มองเป็นค่าว่าง — field "หาย" คือไม่มี key ใน map เท่านั้น (เหมือน Generate)
fileBytes = docxGeneration.GenerateDraft(
templateStream,
values.ToDictionary(kv => kv.Key, kv => kv.Value ?? string.Empty),
template.Id);
}
}
catch (OpenXmlPackageException ex)
{
logger.LogError(ex, "Template file corrupt templateId={TemplateId} key={ObjectKey}",
template.Id, template.ObjectKey);
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
}
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}",
template.Id, currentUser.UserId, fileBytes.Length);
return File(fileBytes, DocxUploadValidation.DocxContentType,
DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow));
}
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary> /// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]

View file

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json;
namespace WordVersioning.Api.Models; namespace WordVersioning.Api.Models;
@ -52,6 +53,14 @@ public class ReplaceTemplateFileRequest
public IFormFile File { get; set; } = null!; public IFormFile File { get; set; } = null!;
} }
/// <summary>body ของ POST /api/templates/{id}/generate-draft (FR-16) — ไม่มี message เพราะไฟล์ร่างยังไม่เกิด version</summary>
public class GenerateDraftRequest
{
/// <summary>map fieldKey → value (ค่า scalar ทั้งหมด) — pattern เดียวกับ GenerateDocumentRequest</summary>
[Required]
public JsonElement Values { get; set; }
}
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder); public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
public record TemplateListItem( public record TemplateListItem(

View file

@ -20,6 +20,10 @@ public class UploadVersionRequest
/// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary> /// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary>
public string? Message { get; set; } public string? Message { get; set; }
/// <summary>GUID ของ template ต้นทาง (FR-16 ไฟล์ร่าง) — ใช้เฉพาะตอน auto-create Document;
/// ไม่ส่ง = เอกสาร track เอง · ส่งมาตอน document มีอยู่แล้ว = เฉย (validate ทุกครั้งที่ส่ง)</summary>
public string? TemplateId { get; set; }
} }
public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf); public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf);

View file

@ -65,11 +65,14 @@ builder.Services
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น // CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
// expose Content-Disposition ให้ fetch ฝั่งเว็บอ่านชื่อไฟล์ (ไทย) ของไฟล์ร่างที่ดาวน์โหลดได้ (FR-16)
// — ไม่ expose แล้ว header จะเป็น null บน cross-origin และชื่อไฟล์หายเงียบๆ
const string corsPolicy = "frontends"; const string corsPolicy = "frontends";
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []) .WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod())); .AllowAnyMethod()
.WithExposedHeaders("Content-Disposition")));
// --- dev HTTPS ------------------------------------------------------------- // --- dev HTTPS -------------------------------------------------------------
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed") // Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")

View file

@ -9,4 +9,9 @@ public static class AddInMetadata
public const string SavedByAddInPropertyName = "SavedByAddIn"; public const string SavedByAddInPropertyName = "SavedByAddIn";
public const string DocumentTrackingIdPropertyName = "DocumentTrackingId"; public const string DocumentTrackingIdPropertyName = "DocumentTrackingId";
/// <summary>FR-16 — stamp ลงไฟล์ร่าง (draft) เท่านั้น: template ต้นทาง · ไม่ stamp
/// SavedByAddIn/DocumentTrackingId เพื่อให้ add-in มองไฟล์ร่างเป็น untracked
/// (ฝั่ง add-in ใช้ชื่อ TEMPLATE_ID_PROPERTY ค่าเดียวกันนี้ ส่งต่อเป็น form field ตอน Start Versioning)</summary>
public const string TemplateIdPropertyName = "TemplateId";
} }

View file

@ -16,6 +16,11 @@ public interface IDocxGenerationService
/// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp /// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp
/// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary> /// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary>
byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId); byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId);
/// <summary>FR-16 — ไฟล์ร่าง (draft): แทนที่ SDT เหมือน Generate แต่ stamp custom property
/// <b>เฉพาะ `TemplateId`</b> (GUID ของ template ต้นทาง) — ไม่ stamp SavedByAddIn/DocumentTrackingId
/// เพื่อให้ add-in มองไฟล์ร่างเป็น untracked จนกว่าผู้ใช้จะกด Start Versioning</summary>
byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid templateId);
} }
/// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary> /// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary>
@ -42,32 +47,67 @@ public class DocxGenerationService : IDocxGenerationService
public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId) public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId)
{ {
// ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose var buffer = CopyToMemory(templateStream);
var buffer = new MemoryStream();
templateStream.CopyTo(buffer);
buffer.Position = 0;
using (var doc = WordprocessingDocument.Open(buffer, true)) using (var doc = WordprocessingDocument.Open(buffer, true))
{ {
foreach (var root in AllContentRoots(doc)) ReplaceSdtValues(doc, values);
{
foreach (var tag in root.Descendants<Tag>().ToList())
{
var key = tag.Val?.Value?.Trim();
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
continue;
ReplaceSdtContent(tag, value);
}
}
SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid); SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid);
SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString()); SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString());
} // dispose เขียน package กลับลง buffer } // dispose เขียน package กลับลง buffer
buffer.Position = 0;
return buffer.ToArray(); return buffer.ToArray();
} }
public byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid templateId)
{
var buffer = CopyToMemory(templateStream);
using (var doc = WordprocessingDocument.Open(buffer, true))
{
ReplaceSdtValues(doc, values);
// stamp ตัวเดียว (FR-16) — ไฟล์ร่างต้องเป็น untracked ในสายตา add-in
SetCustomProperty(doc, AddInMetadata.TemplateIdPropertyName, templateId.ToString());
}
return buffer.ToArray();
}
/// <summary>ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose</summary>
private static MemoryStream CopyToMemory(Stream stream)
{
var buffer = new MemoryStream();
stream.CopyTo(buffer);
buffer.Position = 0;
return buffer;
}
/// <summary>ชื่อไฟล์ของเอกสาร/ไฟล์ร่างที่ generate จาก template: ชื่อ template + timestamp
/// — ตัดอักขระที่ filename ใช้ไม่ได้ออก (ใช้ร่วมทั้ง generate และ generate-draft)</summary>
public static string BuildGeneratedFileName(string templateName, DateTimeOffset now)
{
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
if (clean.Length == 0)
clean = "document";
return $"{clean}_{now:yyyyMMdd-HHmmss}.docx";
}
/// <summary>แทนที่ค่าใน SDT ทุกตัวที่ tag ตรง key ใน values (ใช้ร่วมทั้ง Generate และ GenerateDraft)</summary>
private static void ReplaceSdtValues(WordprocessingDocument doc, IReadOnlyDictionary<string, string> values)
{
foreach (var root in AllContentRoots(doc))
{
foreach (var tag in root.Descendants<Tag>().ToList())
{
var key = tag.Val?.Value?.Trim();
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
continue;
ReplaceSdtContent(tag, value);
}
}
}
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template) /// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary> /// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
private static void ReplaceSdtContent(Tag tag, string value) private static void ReplaceSdtContent(Tag tag, string value)

View file

@ -0,0 +1,38 @@
using System.Globalization;
using WordVersioning.Api.Models;
namespace WordVersioning.Api.Services;
/// <summary>FR-10/FR-16 — validate required + type ของค่าที่กรอก (ใช้ร่วมทั้ง generate และ generate-draft)
/// field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11</summary>
public static class TemplateFieldValidation
{
/// <summary>null = ผ่าน; ไม่ null = ข้อความ error สำหรับ BadRequest (รูปแบบเดียวกับที่เคยอยู่ใน Generate)</summary>
public static string? Validate(IEnumerable<TemplateField> fieldDefs, IReadOnlyDictionary<string, string?> values)
{
var missingRequired = fieldDefs
.Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty)))
.Select(f => f.Key)
.ToList();
if (missingRequired.Count > 0)
return $"กรอก required fields ให้ครบ: {string.Join(", ", missingRequired)}";
var typeErrors = new List<string>();
foreach (var def in fieldDefs)
{
if (!values.TryGetValue(def.Key, out var v) || string.IsNullOrWhiteSpace(v))
continue;
var valid = def.Type switch
{
"number" => decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
"date" => DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out _),
_ => true,
};
if (!valid)
typeErrors.Add(def.Type == "number"
? $"`{def.Key}` ต้องเป็นตัวเลข"
: $"`{def.Key}` ต้องเป็นวันที่ที่ parse ได้ (ISO-8601 เช่น 2026-08-19)");
}
return typeErrors.Count > 0 ? string.Join("; ", typeErrors) : null;
}
}

View file

@ -1,6 +1,6 @@
# ตรวจ Acceptance Criteria 1–18 (Phase 8 + 9 + 10 + 11) # ตรวจ Acceptance Criteria 1–19 (Phase 8 + 9 + 10 + 11 + 12)
> วันที่ตรวจ: 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 > วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · 2026-09-08 (AC-18) · 2026-09-10 (AC-19) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
> >
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน > สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
@ -135,3 +135,32 @@
รายการเต็ม: [แผน FR-15](plans/version-pdf.md). ไม่มีการอ้างว่า unit tests ยืนยัน layout/Word protocol handler ได้. รายการเต็ม: [แผน 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 ตามรายการด้านบน. 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 ตามรายการด้านบน.
## AC-19 — ไฟล์ร่าง (v8)
**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in ใน Word จริงได้)
| เกณฑ์ | หลักฐาน |
|---|---|
| สร้างไฟล์ร่างแล้ว stateless จริง (ไม่เกิด Document/version/field values) + ค่าครบ + formatting รักษาไว้ | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` |
| stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) |
| validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) |
| Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` |
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
| templateId ไม่ใช่ GUID / ไม่พบ → 400 (แม้ document มีอยู่แล้ว) | `Upload_TemplateIdNotGuid_Returns400`, `Upload_TemplateIdUnknown_Returns400`, `Upload_ExistingDocument_BadTemplateId_Returns400` |
| template deactivate หลังสร้างไฟล์ร่าง → ยังผูกได้ | `Upload_TemplateIdDeactivated_StillLinks` |
| document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` |
| endpoint ใหม่ต้อง [Authorize] | `AuthTests` เพิ่ม `POST api/templates/{0}/generate-draft` |
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
| เว็บ: ปุ่มไฟล์ร่าง POST → blob, ชื่อไทยจาก Content-Disposition, 401 retry, !ok → error | `useApi.spec.ts` (4 tests) + CORS expose `Content-Disposition` ที่ `Program.cs` |
ชุดเต็ม: backend **82 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน.
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
- [ ] Mac: เว็บสร้างไฟล์ร่าง → ได้ .docx ชื่อไทยถูกต้อง → เปิดใน Word → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields
- [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน.

View file

@ -0,0 +1,14 @@
# ADR 0002: ไฟล์ร่าง stateless — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning
วันที่: 2026-09-10 · สถานะ: Accepted
## Decision
ไฟล์ร่าง (FR-16) สร้างแบบ stateless: `POST /api/templates/{id}/generate-draft` คืนไฟล์ทันที ไม่สร้าง Document/version ไม่เขียน MinIO. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0.
## Alternatives rejected
- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB + ไฟล์ใน MinIO รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของเอกสารที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ
- stamp SavedByAddIn + DocumentTrackingId ตั้งแต่ตอนสร้างไฟล์ร่าง: add-in จะมองเป็น tracked document แต่ backend ไม่มี record → history ติด 404 ถาวร (ไม่มี 404-handling ในปัจจุบัน)
- ปุ่ม "เปิดใน Word" สำหรับไฟล์ร่างแบบ flow v0 (`ms-word:ofe|u|<url>`): ไม่มี URL เพราะไม่มี object ใน storage — ต้องเสีย statelessness ไปเก็บไฟล์ชั่วคราวพร้อม TTL
## Consequences
ไฟล์ร่างหาย/ไม่ถูกเปิด = ไม่มีอะไรค้างในระบบ (ต้องกรอกฟอร์มใหม่). เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทยได้.

View file

@ -1,7 +1,7 @@
# แผนงาน: ไฟล์ร่าง (draft) — generate docx จาก template โดยยังไม่สร้าง version (FR-16 / spec v8 / Phase 12) # แผนงาน: ไฟล์ร่าง (draft) — generate docx จาก template โดยยังไม่สร้าง version (FR-16 / spec v8 / Phase 12)
> สถานะ: **ยังไม่ implement** — แผนผ่านการ review กับเจ้าของระบบแล้ว (grill session 2026-09-10) พร้อมสั่งทำงานได้ทันที > สถานะ: **implement แล้ว (2026-09-10)** — ติดตามผลที่ `officejs-implementation-plan.md` Phase 12 และ `acceptance-review.md` AC-19 (ค้าง manual บน Word จริง)
> เขียนทับแผนนี้หรือลบทิ้งเมื่อ Phase 12 ถูก merge และ sync เข้า `officejs-implementation-plan.md` เรียบร้อย > แผนนี้เก็บไว้เป็นหลักฐานการตัดสินใจของ grill session — รายละเอียด decision เพิ่มที่ `docs/adr/0002-draft-stateless.md`
## Context ## Context

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8) # Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md) > แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: **Phase 0–10 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง > สถานะ: **Phase 0–12** (Phase 0–11 เสร็จครบ; Phase 12 เหลือ manual บน Word จริง — backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync) > วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
--- ---
@ -631,3 +631,18 @@ npm test # vitest (46 ตัว)
- [x] Web เปิด Word/PDF พร้อม fallback - [x] Web เปิด Word/PDF พร้อม fallback
- [x] Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55) - [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 ค้างตามรายการเดิม - [x] ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม
## Phase 12: ไฟล์ร่าง (draft) — FR-16 (เพิ่มทีหลัง, v8)
| Phase | FR | AC |
|---|---|---|
| 12 (ทีหลัง, v8) | FR-16 (+ขยาย FR-5) | AC-19 |
รายละเอียดเต็ม: [fr16-draft-generation-plan.md](fr16-draft-generation-plan.md) — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field `templateId` ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง")
- [x] Spec v8 + glossary + ADR 0002
- [x] Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload + CORS expose `Content-Disposition`
- [x] Add-in: อ่าน `TemplateId` + ส่ง `templateId` เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane
- [x] Web: ปุ่ม "สร้างไฟล์ร่าง" + `downloadFile` รับ `init` (POST → blob)
- [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → เปิดใน Word → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web

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)
> ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง > ฉบับแก้ไข 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 ทุกหน้า
@ -292,6 +293,14 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ - เว็บ 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 จริง - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8)
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — **stateless**: ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` และไม่เขียน MinIO; คืนไฟล์ .docx ทันที (200 + attachment, ชื่อ `ชื่อtemplate_yyyyMMdd-HHmmss.docx` — ไม่ใช่ 201 เพราะไม่มีอะไรถูกสร้าง)
- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message)
- stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404")
- **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0)
- Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → ดาวน์โหลดไฟล์ทันที (browser download ผ่าน Bearer — ชื่อไฟล์อ่านจาก `Content-Disposition` ที่ CORS ต้อง expose); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์/ปุ่มเปิดใน Word (ไม่มี URL ให้ `ms-word:ofe`) · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
## 7. Data Model (Backend) ## 7. Data Model (Backend)
```csharp ```csharp
@ -362,6 +371,7 @@ Custom Document Properties:
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) | | `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
| `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) | | `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
| `LastVersionTimestamp` | ISO-8601 UTC timestamp | | `LastVersionTimestamp` | ISO-8601 UTC timestamp |
| `TemplateId` | GUID ของ template ต้นทาง — มีเฉพาะไฟล์ร่าง (FR-16); backend stamp ตัวเดียวตอน generate-draft, add-in อ่านตอน untracked เพื่อแนบ `templateId` ตอน Start Versioning (เมื่อเริ่ม track แล้วค่านี้ยังอยู่ในไฟล์แต่ไม่ถูกใช้) |
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth): Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
```xml ```xml
@ -419,6 +429,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
--- ---
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 จริง 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 จริง
19. (v8, AC-19) สร้างไฟล์ร่างจากฟอร์มเว็บ → ได้ .docx ค่าครบ ชื่อไทยถูกต้อง โดย**ไม่เกิด Document/version ใดๆ ในระบบ** → เปิดใน Word แล้ว add-in แสดงสถานะ untracked พร้อม hint ไฟล์ร่าง → กด Start Versioning → Document ถูกสร้าง ผูก template ต้นทาง + **version 1** + field values sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · template deactivate ผูกได้ · Save & Version ถัดไปได้ v2, v3… โดยไม่ส่ง templateId · flow สร้างเอกสาร v0 (FR-10) ยังทำงานได้คู่กัน
## 11. งานที่ Claude Code ควรทำ ## 11. งานที่ Claude Code ควรทำ
@ -463,3 +474,8 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
- Add-in: PDF capture + multipart + warning + history PDF - Add-in: PDF capture + multipart + warning + history PDF
- Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0 - Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0
- Web: เปิด Word/PDF + fallback + history v0/v1+ - Web: เปิด Word/PDF + fallback + history v0/v1+
### เพิ่มเติม v8 (FR-16)
- Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create) + CORS expose `Content-Disposition`
- Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + `downloadFile` รับ `init` (POST + JSON → blob)

View file

@ -72,9 +72,10 @@ export function useApi() {
/** /**
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download * ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in) * (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
* init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์
*/ */
async function downloadFile(path: string, fallbackName: string): Promise<void> { async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise<void> {
const res = await request(path) const res = await request(path, init)
if (!res.ok) throw await toApiError(res) if (!res.ok) throw await toApiError(res)
const blob = await res.blob() const blob = await res.blob()

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด /** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */ * FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง
* FR-16 — ปุ่ม "สร้างไฟล์ร่าง": generate docx แบบ stateless (ไม่เกิด Document/version) ดาวน์โหลดทันที */
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api' import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute() const route = useRoute()
const { apiJson } = useApi() const { apiJson, downloadFile } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen() const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId)) const templateId = computed(() => String(route.params.templateId))
@ -25,6 +26,11 @@ 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)
/** FR-16 — ไฟล์ร่าง: ฟอร์มอยู่ต่อหลังดาวน์โหลด (ไม่มี result card เพราะไม่มี documentId) */
const draftSubmitting = ref(false)
const draftSuccess = ref<string | null>(null)
const draftError = 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))
useHead({ useHead({
@ -77,6 +83,8 @@ async function load() {
async function submit() { async function submit() {
submitError.value = null submitError.value = null
downloadError.value = null downloadError.value = null
draftSuccess.value = null
draftError.value = null
issues.value = Object.fromEntries( issues.value = Object.fromEntries(
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]), validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
) )
@ -97,11 +105,40 @@ async function submit() {
} }
} }
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
async function submitDraft() {
draftSuccess.value = null
draftError.value = null
submitError.value = null
issues.value = Object.fromEntries(
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
)
if (Object.keys(issues.value).length > 0) return
draftSubmitting.value = true
try {
await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: values.value }),
})
draftSuccess.value =
'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
} catch (err) {
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
} finally {
draftSubmitting.value = false
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */ /** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
function resetForm() { function resetForm() {
resetOpen() resetOpen()
result.value = null result.value = null
submitError.value = null submitError.value = null
draftSuccess.value = null
draftError.value = null
downloadError.value = null downloadError.value = null
issues.value = {} issues.value = {}
values.value = { ...blankValues(), ...prefill.value } values.value = { ...blankValues(), ...prefill.value }
@ -254,6 +291,7 @@ onMounted(load)
maxlength="2048" maxlength="2048"
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก" placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
/> />
<p class="mt-1 text-xs text-muted">ใช้กับการสร้างเอกสาร (version 0) เท่านั้น — ไฟล์ร่างยังไม่มี version จึงไม่ใช้คำอธิบายนี้</p>
</div> </div>
<div v-if="sortedFields.length === 0" class="empty-state"> <div v-if="sortedFields.length === 0" class="empty-state">
@ -263,15 +301,30 @@ onMounted(load)
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox> <AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
<!-- FR-16 — ไฟล์ร่าง: ดาวน์โหลดสำเร็จแล้วฟอร์มอยู่ต่อ ผู้ใช้เปิดไฟล์ใน Word แล้วค่อย Start Versioning -->
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end"> <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<button
type="button"
:disabled="submitting || draftSubmitting || sortedFields.length === 0 || !detail.isActive"
class="btn btn-secondary w-full sm:w-auto"
@click="submitDraft"
>
{{ draftSubmitting ? 'กำลังสร้างไฟล์ร่าง…' : 'สร้างไฟล์ร่าง (ยังไม่ version)' }}
</button>
<button <button
type="submit" type="submit"
:disabled="submitting || sortedFields.length === 0 || !detail.isActive" :disabled="submitting || draftSubmitting || 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 0)' }} {{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
</button> </button>
</div> </div>
<p class="text-xs text-muted">
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
</p>
</form> </form>
</template> </template>
</section> </section>

117
web/test/useApi.spec.ts Normal file
View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, useApi } from '../app/composables/useApi'
/**
* useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ
* POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้)
*/
const getAccessToken = vi.fn()
const fetchMock = vi.fn()
/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */
function fakeResponse(options: {
ok?: boolean
status?: number
headers?: Record<string, string>
body?: string
}): Response {
const lower = Object.fromEntries(
Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
)
return {
ok: options.ok ?? true,
status: options.status ?? 200,
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
blob: async () => new Blob([options.body ?? 'docx-bytes']),
text: async () => options.body ?? '',
} as unknown as Response
}
let clicked: HTMLAnchorElement | null = null
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
beforeEach(() => {
vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } }))
vi.stubGlobal('useAuth', () => ({ getAccessToken }))
vi.stubGlobal('fetch', fetchMock)
getAccessToken.mockReset().mockResolvedValue('token-1')
fetchMock.mockReset()
clicked = null
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) {
clicked = this
})
URL.createObjectURL = vi.fn(() => 'blob:mock')
URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
})
describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => {
it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => {
fetchMock.mockResolvedValue(
fakeResponse({
headers: {
// .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย
'Content-Disposition':
"attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx",
},
}),
)
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(clicked?.download).toBe('ไฟล์ร่าง.docx')
})
it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => {
fetchMock.mockResolvedValue(fakeResponse({}))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(clicked?.download).toBe('fallback.docx')
})
it('retries once with a refreshed token on 401 before saving', async () => {
getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1'))
fetchMock
.mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 }))
.mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } }))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2')
expect(clicked?.download).toBe('draft.docx')
})
it('throws ApiError with the backend message when the response is not ok', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }),
)
await expect(
useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }),
).rejects.toMatchObject({
constructor: ApiError,
status: 400,
message: 'กรอก required fields ให้ครบ: employee_name',
})
expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น
})
})

View file

@ -1,3 +1,4 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM) // Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
@ -9,4 +10,11 @@ export default defineConfig({
setupFiles: ['./test/setup.ts'], setupFiles: ['./test/setup.ts'],
include: ['test/**/*.spec.ts'], include: ['test/**/*.spec.ts'],
}, },
// alias ~/ ชี้ srcDir = app/ ตาม Nuxt — จำเป็นตอน spec import ไฟล์ที่ลาก useAuth
// (useAuth อ้าง ~/utils/oidc — เดิมไม่มี spec ไหนแตะ useApi จึงไม่เคยโดน)
resolve: {
alias: {
'~': fileURLToPath(new URL('./app', import.meta.url)),
},
},
}) })