All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m27s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Replace the in-memory versioningService with real calls to the docver group (/api/documents/*) via apiClient with the ticket-issued Bearer; keep the mock only for ?addindemo (authService.isDemoMode). - uploadVersion: multipart POST /api/documents/versions (file, optional pdf when non-empty, clientVersionId, fields JSON, trimmed message, fileName) — identity fields are never sent (userId/userName/templateId in the form = 400 per the 2026-10-22 contract; docId comes from the fid claim) - getVersions: GET → DocumentVersionDTO mapped to VersionRecord (adds optional createdByUserName; history panel shows it with userId fallback) - getDownloadLink: GET presigned URL (+ ?format=pdf) - apiClient.toUrl: empty NEXT_PUBLIC_ADDIN_API_BASE_URL = same-origin through withBasePath (/docedit/api/...); the env is now only for a split deployment (must include basePath, HTTPS) - documentId/templateId params stay in signatures but are not sent - tests: versioningService.test.ts covers both modes (real sign-in via signInWithTicket, FormData contract assertions, DTO mapping, format query, 401 -> AuthRequiredError, demo mock without network) - docs sync: WORDADDIN-DEV (new "การรันกับ backend จริง" section), addin-ui-flow status note, PLAN-DOCVER §8, PLAN-ADDIN-AUTH §12, CLAUDE.md Verified: tsc clean, 286/286 tests, full E2E against MinIO (ticket -> token -> upload docx / docx+pdf -> history -> presigned download of both formats). Co-Authored-By: Claude Code <noreply@anthropic.com>
134 lines
5.6 KiB
TypeScript
134 lines
5.6 KiB
TypeScript
import { config } from "../config";
|
|
import { withBasePath } from "@/utils/base-path";
|
|
import * as authService from "./authService";
|
|
|
|
/**
|
|
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService
|
|
* ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
|
|
* โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError:
|
|
* UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
|
* พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
|
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
|
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
|
*/
|
|
|
|
/** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */
|
|
export class AuthRequiredError extends Error {
|
|
constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
|
|
super(message);
|
|
this.name = "AuthRequiredError";
|
|
}
|
|
}
|
|
|
|
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export interface ApiRequestOptions {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: BodyInit | null;
|
|
}
|
|
|
|
/**
|
|
* path เต็มเสมอ — ไม่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` = same-origin ผ่าน withBasePath
|
|
* (ได้ /docedit/api/… ถูกต้องตาม invariant #12 — API ของกลุ่ม docver อยู่แอปนี้เอง)
|
|
* · ตั้งค่า = ชี้ backend คนละ origin (ต้องรวม basePath แล้ว เช่น https://host/docedit — ใช้ตอน
|
|
* deploy add-in กับ API คนละตัว) และต้องเป็น HTTPS เสมอ (webview ของ Word บล็อก http)
|
|
*/
|
|
function toUrl(path: string): string {
|
|
if (path.startsWith("http")) return path;
|
|
return config.apiBaseUrl ? `${config.apiBaseUrl}${path}` : withBasePath(path);
|
|
}
|
|
|
|
async function doFetch(
|
|
path: string,
|
|
options: ApiRequestOptions,
|
|
accessToken: string
|
|
): Promise<Response> {
|
|
const headers = new Headers(options.headers ?? {});
|
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
return fetch(toUrl(path), {
|
|
method: options.method ?? "GET",
|
|
headers,
|
|
body: options.body,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน
|
|
* (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง
|
|
* ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์
|
|
* ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง
|
|
*/
|
|
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
|
const accessToken = await authService.getAccessToken();
|
|
if (!accessToken) {
|
|
throw new AuthRequiredError();
|
|
}
|
|
const response = await doFetch(path, options, accessToken);
|
|
if (response.status !== 401) {
|
|
return response;
|
|
}
|
|
if (await authService.renewTokenFromTicket()) {
|
|
const retryToken = await authService.getAccessToken();
|
|
if (retryToken) {
|
|
const retry = await doFetch(path, options, retryToken);
|
|
if (retry.status !== 401) {
|
|
return retry;
|
|
}
|
|
}
|
|
}
|
|
throw new AuthRequiredError();
|
|
}
|
|
|
|
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
|
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const text = (await response.text()).trim();
|
|
if (!text) {
|
|
return fallback;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
|
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
|
if (typeof detail === "string" && detail) {
|
|
return detail;
|
|
}
|
|
} catch {
|
|
// plain text — ใช้ตรงๆ
|
|
}
|
|
return text;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
|
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
|
const response = await apiFetch(path);
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
|
export async function apiSendExpectJson<T>(
|
|
path: string,
|
|
options: ApiRequestOptions,
|
|
fallbackError: string
|
|
): Promise<T> {
|
|
const response = await apiFetch(path, options);
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|