diff --git a/CLAUDE.md b/CLAUDE.md index d58c576..c576802 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ 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–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง 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` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 59 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง 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` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 6d7c245..42e8e62 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -156,11 +156,12 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) +ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) - [ ] Mac: เว็บสร้างไฟล์ร่าง → **Word เปิดไฟล์ทันที** พร้อมลิงก์สำรอง → ตรวจการตัดคำ/จัดหน้าภาษาไทยของ field ที่ค่ายาว (เช่น Detail ของ T14) ตรงตาม template → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields +- [ ] Mac: **Word cold-start** (ปิด Word ทิ้งแล้วกด "เปิดใน Word" ครั้งแรก) — ยืนยันว่า fallback ดาวน์โหลดไฟล์ให้อัตโนมัติ + กดครั้งที่สองขณะ Word เปิดอยู่แล้วเอกสารเปิดสำเร็จ (v9.1 แก้ตาม feedback จริง: ครั้งแรก Word แสดงหน้า Recent แทนเอกสาร — ข้อจำกัด `ms-word:ofe` บน macOS ตอน cold-start) - [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1 รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md). @@ -170,3 +171,5 @@ QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีท QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server) + +QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.1): **PASS**, 1 round (2026-09-10) — reviewer stash-verify ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เพิ่ม test re-click ยกเลิก auto-download ค้าง — save ครั้งเดียว URL ล่าสุด) · ยอมรับ 2 พร้อมเหตุผล: (1) ถ้า Word cold-start มาถึงหน้าต่างภายใน 3 วิ heuristic เห็น "สลับแอป" จึงไม่ auto-download ทั้งที่เอกสารไม่เปิด — เป็นข้อจำกัดเชิงหลักการของ heuristic (แยก "Word เปิด" กับ "เปิดเอกสาร" ไม่ได้จาก web) spec ระบุชัดว่าไม่ใช่หลักฐานความสำเร็จ + มี manual check item ครอบ (2) ถ้า Word เปิดเอกสารสำเร็จแต่ช้ากว่า 3 วิ ผู้ใช้ได้ไฟล์ซ้ำใน Downloads — ต้นทุนต่ำยอมรับได้ · ตรวจแล้ว: PDF path ไม่โดน auto-download, ทุก docx entry point (version rows/v0/ไฟล์ร่าง) ผ่าน openDocxUrl ครบ diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index d66d47f..fdf182e 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -645,5 +645,5 @@ npm test # vitest (46 ตัว) - [x] Web: ปุ่ม "สร้างไฟล์ร่าง" - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) +- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + 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 diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 173aa5b..ccc1128 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -291,7 +291,7 @@ POST /api/documents/generate { templateId, values: { : } } - เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable - docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่ - upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow` -- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ +- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~3s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ — ถ้าไม่เห็นการสลับแอปภายในหน้าต่างรอ ให้**ดาวน์โหลดไฟล์ให้อัตโนมัติ** + แนะนำเปิดจาก Downloads หรือกดซ้ำ (v9.1: `ms-word:ofe` บน macOS ตอน Word cold-start มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — กดครั้งที่สองขณะ Word เปิดอยู่แล้วจะสำเร็จ) - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) @@ -301,7 +301,7 @@ POST /api/documents/generate { templateId, values: { : } } - **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก 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: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic เตือนเมื่อไม่เห็นการสลับแอป (กลไกเดียวกับ flow เปิด v0 — `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม +- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic 3 วิ (สลับแอปไม่ได้ = ดาวน์โหลดให้อัตโนมัติ — กลไกเดียวกับ flow เปิด v0 `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม ## 7. Data Model (Backend) diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index 71e3527..7214b06 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -12,6 +12,13 @@ export function useVersionOpen() { let generation = 0 let pendingTab: Window | null = null + /** + * รอสังเกตการสลับไป Word (blur/visibilitychange) กี่มิลลิวินาทีก่อนสรุปว่าเปิดไม่สำเร็จ — + * ยาวพอให้ Word cold-start โอกาสเปิดได้ทัน แต่ไม่นานจนผู้ใช้งง (ms-word:ofe บน macOS ตอน Word + * ยังไม่เปิดค้างอยู่มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — ข้อจำกัดของ platform) + */ + const WORD_SWITCH_WAIT_MS = 3000 + function resetOpen() { generation++ pendingTab?.close() @@ -23,7 +30,18 @@ export function useVersionOpen() { openNotice.value = null } - /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe พร้อม heuristic เตือนเมื่อไม่เห็นการสลับแอป — + /** ให้เบราว์เซอร์เซฟ URL ลง Downloads (presigned URL ส่ง disposition: attachment อยู่แล้ว — + * หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */ + function saveToDownloads(url: string) { + const a = document.createElement('a') + a.href = url + document.body.append(a) + a.click() + a.remove() + } + + /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe — ถ้าไม่เห็นการสลับแอปภายใน WORD_SWITCH_WAIT_MS + * ถือว่าเปิดไม่สำเร็จ: ดาวน์โหลดไฟล์ให้อัตโนมัติ + แจ้งผู้ใช้ (กดซ้ำตอน Word เปิดอยู่แล้วจะสำเร็จ) * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ function openDocxUrl(url: string) { resetOpen() @@ -34,9 +52,13 @@ export function useVersionOpen() { window.addEventListener('blur', onBlur) document.addEventListener('visibilitychange', onVisibility) const timer = window.setTimeout(() => { - if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word' + if (!switched) { + openNotice.value = + 'Word ไม่ได้เปิดเอกสารอัตโนมัติ (มักเกิดตอน Word เพิ่งถูกเปิดขึ้นครั้งแรก) — ดาวน์โหลดไฟล์ให้แล้วที่โฟลเดอร์ Downloads ให้เปิดจากที่นั่น หรือกดเปิดใน Word อีกครั้งเมื่อ Word เปิดอยู่แล้ว' + saveToDownloads(url) + } cleanup() - }, 1500) + }, WORD_SWITCH_WAIT_MS) cleanup = () => { window.clearTimeout(timer) window.removeEventListener('blur', onBlur) diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index 91e8150..8d285ac 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -87,11 +87,12 @@ describe('version open lifecycle', () => { it('offers Word fallback after timeout and removes listeners on unmount', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) const remove = vi.spyOn(window, 'removeEventListener') + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' }) const state = mount() await state.openVersion('d', 'v') expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb') - vi.advanceTimersByTime(1500) + vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() expect(state.fallbackUrl.value).toBeTruthy() app.unmount() @@ -99,12 +100,58 @@ describe('version open lifecycle', () => { }) it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const state = mount() state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') expect(apiJson).not.toHaveBeenCalled() expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb') - vi.advanceTimersByTime(1500) + vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() }) + it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') + // ยังไม่มีอะไรเกิดก่อนหมดเวลารอ + vi.advanceTimersByTime(2999) + expect(saved).toHaveLength(0) + expect(state.openNotice.value).toBeNull() + // ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล + vi.advanceTimersByTime(1) + expect(saved).toHaveLength(1) + expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb') + expect(state.openNotice.value).toContain('Downloads') + }) + it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') + window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word + vi.advanceTimersByTime(3000) + expect(saved).toHaveLength(0) + expect(state.openNotice.value).toBeNull() + }) + it('re-click cancels the pending auto-download — only the latest URL is saved once', () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/first.docx') + vi.advanceTimersByTime(2000) + state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า + vi.advanceTimersByTime(3000) + expect(saved).toHaveLength(1) + expect(saved[0].href).toBe('https://files.example/second.docx') + }) })