v9.2 follow-up: skip ms-word on Mac by default + sideload notes

- Web: ยิง ms-word:ofe คู่กับการดาวน์โหลดเฉพาะ non-Mac โดย default —
  บน Mac ไม่ยิงเพราะ Word 16.101+ เมิน URL แล้วเด้งหน้าเลือกไฟล์
  ทุกครั้งจนรำคาญ (override ด้วย NUXT_PUBLIC_OPEN_IN_WORD=on|off)
- CLAUDE.md: จดสูตร sideload บน Mac — ปิด Word ก่อน npm start
  (Word อ่าน manifest ตอน startup เท่านั้น), ปล่อย dev server รันทิ้ง,
  แก้โค้ดใช้ hot reload
- Spec/plan sync + web 60/60 + build; QA reviewer PASS (1 round,
  minors 3 — แก้ 2 ปฏิเสธ 1 พร้อมเหตุผล)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 13:59:45 +07:00
parent ee4d8573ee
commit 83a949cfa0
7 changed files with 52 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 86 / add-in 66 / web 58 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง 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 86 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง 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`
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
@ -83,6 +83,9 @@ npx office-addin-dev-certs install # ครั้งแรก
npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest (symlink)
npm test
# ⚠️ sideload บน Mac: ปิด Word ทุกหน้าต่าง (Cmd+Q) ก่อน `npm start` — Word อ่าน manifest ตอน
# startup เท่านั้น ถ้าเปิดค้างไว้ manifest ใหม่จะไม่ถูกโหลด (ต้อง stop/start) · ปล่อย dev server
# รันทิ้งไว้ตลอด session (ถ้าตาย task pane โหลดไม่ได้) · แก้โค้ด add-in ใช้ hot reload ได้เลยไม่ต้อง restart
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
# web portal (http://localhost:5173 — ห้ามใช้ 3000)

View file

@ -157,7 +157,7 @@ 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.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **58 ผ่าน** + production build ผ่าน
ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
@ -189,3 +189,5 @@ QA reviewer รอบ hotfix อักขระควบคุม 0x0B จาก
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 ครบ
QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word"
QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง)

View file

@ -646,5 +646,5 @@ npm test # vitest (46 ตัว)
- [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 59) + QA reviewer PASS (2 rounds, 2026-09-10)
- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 58
- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่เฉพาะ non-Mac โดย default — `NUXT_PUBLIC_OPEN_IN_WORD` override, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 60
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web

View file

@ -292,7 +292,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 แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|<url>` ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะ Word for Mac 16.101+ เมิน URL ทุกรูปแบบแต่เด้งหน้าเลือกไฟล์เหมือนสลับแอปจริง ทำให้ heuristic โดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10)
- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|<url>` ควบคู่แบบ best-effort **เฉพาะ non-Mac โดย default** — บน Mac ไม่ยิงเพราะ Word 16.101+ ไม่เพียงเมิน URL แต่เด้งหน้าเลือกไฟล์ทุกครั้งจนรำคาญ (override ได้ด้วย env `NUXT_PUBLIC_OPEN_IN_WORD=on|off`) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะโดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10)
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9)

View file

@ -4,6 +4,14 @@ import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/v
/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */
export function useVersionOpen() {
const { apiJson } = useApi()
// อ่านตอน setup (มี nuxt context) — จะยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่
// (v9.2): Word for Mac 16.101+ เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์ทุกครั้ง — default
// จึงไม่ยิงบน Mac; override ด้วย env NUXT_PUBLIC_OPEN_IN_WORD=on|off เพื่อบังคับทุกแพลตฟอร์ม
const openInWord = String(useRuntimeConfig().public.openInWord ?? '').toLowerCase()
const isMac = typeof navigator !== 'undefined'
&& /Mac/i.test(navigator.platform || navigator.userAgent)
const fireWordScheme = openInWord === 'on' ? true : openInWord === 'off' ? false : !isMac
const openingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fallbackUrl = ref<string | null>(null)
@ -32,16 +40,16 @@ export function useVersionOpen() {
}
/** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe
* ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) —
* เลิกพึ่ง heuristic การสลับแอป เพราะ Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ
* โดยเด้งหน้าเลือกไฟล์ (เหมือนสลับแอปจริง — heuristic โดนหลอกจนไม่ดาวน์โหลดให้)
* ควบคู่แบบ best-effort เฉพาะเมื่อ fireWordScheme (default: ไม่ยิงบน Mac — ดูคอมเมนต์ด้านบน)
* ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */
function openDocxUrl(url: string) {
resetOpen()
fallbackUrl.value = url
saveToDownloads(url)
openNotice.value =
'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)'
openNotice.value = fireWordScheme
? 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)'
: 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย'
if (fireWordScheme)
window.location.assign(wordOpenUrl(url))
}

View file

@ -27,6 +27,9 @@ export default defineNuxtConfig({
// Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง)
keycloakAuthority: 'https://localhost:8443/realms/word-versioning',
keycloakClientId: 'word-versioning-web',
// ยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ — '' = auto (ไม่ยิงบน Mac เพราะ Word 16.101+
// เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์รำคาญ, ยิงบน Windows/อื่นๆ), 'on'/'off' = บังคับทุกที่ (v9.2)
openInWord: '',
},
},

View file

@ -20,6 +20,8 @@ function mount() {
beforeEach(() => {
vi.stubGlobal('useApi', () => ({ apiJson }))
// default: auto — happy-dom ไม่ใช่ Mac UA จึงยิง ms-word ควบคู่ (แต่ละ test ที่ต้องการก็ stub ทับได้)
vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: '' } }))
tab = { opener: {}, closed: false, location: { replace }, close }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
})
@ -130,4 +132,28 @@ describe('version open lifecycle', () => {
expect(saved[1].href).toBe('https://files.example/second.docx')
expect(state.fallbackUrl.value).toBe('https://files.example/second.docx')
})
it('OPEN_IN_WORD=off: ไม่ยิง ms-word เลย — ดาวน์โหลดอย่างเดียว (Word 16.101+ บน Mac เด้งหน้าเลือกไฟล์ทุกครั้ง)', () => {
vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'off' } }))
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const state = mount()
state.openDocxUrl('https://files.example/draft.docx')
expect(assign).not.toHaveBeenCalled()
expect(state.openNotice.value).not.toContain('อัตโนมัติ') // ข้อความไม่เกี้ยวเรื่องเปิดอัตโนมัติ
})
it('auto บน Mac: ไม่ยิง ms-word (default), override on แล้วยิงแม้บน Mac', () => {
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel')
let state = mount() // default '' → auto → Mac = ไม่ยิง
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
state.openDocxUrl('https://files.example/draft.docx')
expect(assign).not.toHaveBeenCalled()
app.unmount()
host.remove()
vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'on' } }))
state = mount()
state.openDocxUrl('https://files.example/draft.docx')
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx')
})
})