Hotfix: auto-download fallback when Word cold-start drops the open URL

ผู้ใช้จริง (Mac): กด "เปิดใน Word" ตอน Word ยังไม่เปิดค้างอยู่ — Word
cold-start แล้ว URL event หลุด แสดงหน้า Recent แทนเอกสาร (ข้อจำกัด
ms-word:ofe บน macOS; กลไกนี้ผ่าน manual test ตอน Word เปิดอยู่ก่อนแล้ว)

- useVersionOpen.openDocxUrl: หน้าต่างรอ 1.5s → 3s และเมื่อไม่เห็นการ
  สลับแอป → ดาวน์โหลดไฟล์ให้อัตโนมัติ (anchor → presigned attachment)
  + ข้อความแนะนำเปิดจาก Downloads หรือกดซ้ำเมื่อ Word เปิดอยู่แล้ว
  — ใช้ร่วมทุกจุดเปิด docx (version rows, v0, ไฟล์ร่าง) PDF ไม่เกี่ยว
- Tests: timing edge 2999/3000 + href + notice, ไม่ download เมื่อสลับ
  แอปจริง, re-click ยกเลิก timer เก่า (web 59/59 + build)
- Spec FR-15/16 (~3s + auto-download + หมายเหตุ cold-start) +
  acceptance-review เพิ่ม manual check item
- QA reviewer PASS (1 round; minors 3 — แก้ 1 ยอมรับ 2 พร้อมเหตุผล)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 12:22:19 +07:00
parent b9b227926d
commit e0558e3cf4
6 changed files with 82 additions and 10 deletions

View file

@ -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`
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)

View file

@ -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 ครบ

View file

@ -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

View file

@ -291,7 +291,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable
- docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่
- upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow`
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ 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: { <fieldKey>: <value> } }
- **ค่าที่แทนที่เขียนที่ 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|<presigned-url>` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic เตือนเมื่อไม่เห็นการสลับแอป (กลไกเดียวกับ flow เปิด v0 — `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|<presigned-url>` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic 3 วิ (สลับแอปไม่ได้ = ดาวน์โหลดให้อัตโนมัติ — กลไกเดียวกับ flow เปิด v0 `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
## 7. Data Model (Backend)

View file

@ -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)

View file

@ -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')
})
})