eCourt-Doc-Edit/web/app/composables/useApi.ts
Suphonchai Phoonsawat 70f71f4c5c Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)
แก้ตามผลใช้งานจริง 2 ประเด็น:
1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ
   ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays)
   + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง
   (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0)
2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ
   ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word
   จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด
   rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft)

- ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init,
  useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19
- Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings
  แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup
  ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00

73 lines
2.9 KiB
TypeScript

/**
* fetch wrapper ของ web portal — แนบ Bearer ทุก request,
* โดน 401 → force refresh token แล้ว retry ครั้งเดียว (ยัง 401/refresh พัง → throw ให้ UI กลับไปหน้า login)
*/
import { AuthError } from './useAuth'
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
/** ดึงข้อความ error จาก response ของ backend (string ตรงๆ หรือ ProblemDetails) */
async function toApiError(res: Response): Promise<ApiError> {
const fallback = `เรียก API ไม่สำเร็จ (HTTP ${res.status})`
try {
const text = (await res.text()).trim()
if (!text) return new ApiError(res.status, fallback)
try {
const json = JSON.parse(text) as { title?: string; detail?: string }
const msg = json.detail ?? json.title
return new ApiError(res.status, msg ?? text)
} catch {
return new ApiError(res.status, text) // backend คืน message เป็น string เปล่าๆ (เช่น BadRequest)
}
} catch {
return new ApiError(res.status, fallback)
}
}
export function useApi() {
const config = useRuntimeConfig()
const base = (config.public.apiBaseUrl as string).replace(/\/+$/, '')
const { getAccessToken } = useAuth()
async function request(path: string, init: RequestInit = {}): Promise<Response> {
const doFetch = async (forceRefresh: boolean): Promise<Response> => {
let token: string
try {
token = await getAccessToken(forceRefresh)
} catch (err) {
// refresh token ตาย — AuthError พร้อมข้อความชี้นำให้ login ใหม่ (สถานะ auth ถูกเคลียร์ไปแล้ว)
throw err instanceof AuthError ? new ApiError(401, err.message) : err
}
return fetch(base + path, {
...init,
headers: { ...(init.headers as Record<string, string> | undefined), Authorization: `Bearer ${token}` },
})
}
let res = await doFetch(false)
if (res.status === 401) res = await doFetch(true) // retry ครั้งเดียว
return res
}
/** เรียก API ที่คืน JSON (หรือ 204 → undefined) — !ok จะ throw ApiError */
async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T | undefined> {
const res = await request(path, init)
if (!res.ok) throw await toApiError(res)
if (res.status === 204) return undefined
return (await res.json()) as T
}
/** upload multipart — ห้าม set Content-Type เอง (browser ต้องใส่ boundary ให้) */
async function apiUpload<T>(path: string, form: FormData, method: 'POST' | 'PUT' = 'POST'): Promise<T> {
return (await apiJson<T>(path, { method, body: form })) as T
}
return { request, apiJson, apiUpload }
}