eCourt-Doc-Edit/web/app/composables/useApi.ts
Suphonchai Phoonsawat b3c1c667f7 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>
2026-09-10 10:51:13 +07:00

109 lines
4.5 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
}
/**
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
* init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์
*/
async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise<void> {
const res = await request(path, init)
if (!res.ok) throw await toApiError(res)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName)
document.body.append(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
return { request, apiJson, apiUpload, downloadFile }
}
/** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */
function filenameFrom(header: string | null, fallback: string): string {
if (!header) return fallback
const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i)
if (utf8) {
try {
return decodeURIComponent(utf8[1].replace(/^"|"$/g, ''))
} catch {
/* ผ่านต่อ */
}
}
const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i)
if (plain) return (plain[1] ?? plain[2]).trim()
return fallback
}