feat(addin): unmock versioning — real upload/download against docver API
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>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-27 12:52:29 +07:00
parent 4200176239
commit 37b18c58c2
11 changed files with 373 additions and 68 deletions

View file

@ -32,6 +32,8 @@ export interface VersionRecord {
/** ISO-8601 */
createdAt: string;
createdByUserId: string;
/** ชื่อผู้สร้าง — ส่วนเสริมของ DTO กลุ่ม docver (mock/demo อาจไม่มี → UI fallback เป็น userId) */
createdByUserName?: string;
/** SHA256 hex */
contentHash: string;
fields: FieldsSnapshot | null;

View file

@ -1,4 +1,5 @@
import { config } from "../config";
import { withBasePath } from "@/utils/base-path";
import * as authService from "./authService";
/**
@ -36,8 +37,15 @@ export interface ApiRequestOptions {
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 {
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
if (path.startsWith("http")) return path;
return config.apiBaseUrl ? `${config.apiBaseUrl}${path}` : withBasePath(path);
}
async function doFetch(

View file

@ -27,12 +27,20 @@ let username: string | null = null;
let currentFid: string | null = null;
/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */
let currentTicket: { data: string; hmac: string } | null = null;
/** โหมด ?addindemo (dev-only) — เปิดโดย signInForDemo เท่านั้น */
let demoMode = false;
/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
export function signInForDemo(): void {
username = config.mockUsername;
currentFid = null;
currentTicket = null;
demoMode = true;
}
/** โหมด ?addindemo หรือเปล่า — versioningService ใช้ mock แทน backend จริงเฉพาะโหมดนี้ */
export function isDemoMode(): boolean {
return demoMode;
}
/**
@ -139,6 +147,7 @@ export async function signInWithTicket(input: {
currentFid = ticket.fid;
currentTicket = { data: ticketData, hmac: ticketHmac };
username = ticket.uname;
demoMode = false; // sign-in จริงเคลียร์โหมด demo (ถ้าเคยเปิดค้างใน page เดียวกัน)
return { ok: true };
}

View file

@ -1,26 +1,146 @@
/**
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
* Version API ฝั่ง client — **ของจริง** (กลุ่ม docver /api/documents/* ผ่าน apiClient
* ซึ่งแนบ Bearer แล้ว — contract ตาม docs/PLAN-DOCVER.md) · เหลือ mock เฉพาะโหมด
* `?addindemo` (dev-only ดู UI ใน browser ไม่ต้องมี backend/MinIO — authService.isDemoMode)
*
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
* - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ
* POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี
* segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId
* (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST
* /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ตามต้นฉบับ
* D:\word-addin-poc\addin\src\services\versioningService.ts — versionFlow เรียกต่อได้เดิม
* - **เอกสารถูกระบุด้วย fid ใน Bearer token** (ไม่ใช่ documentId GUID ที่ stamp ในไฟล์ —
* 2026-10-26 contract ถอด {docId} ออกจาก URL ทุก endpoint) พารามิเตอร์ documentId จึงเหลือ
* ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims
* ของ token — ส่งมาใน form = 400)
* - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400)
* - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่
* ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block)
* = ใช้ memory อย่างเดียว ไม่ throw
*/
import * as authService from "./authService";
import { generateGuid } from "./metadataService";
import { apiGetJson, apiSendExpectJson } from "./apiClient";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
// ---------------------------------------------------------------------------
// ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient)
// ---------------------------------------------------------------------------
/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */
interface DocumentVersionDTO {
versionId: string;
versionNumber: number;
createdAt: string;
createdByUserId: string;
createdByUserName: string;
contentHash: string;
fields: Record<string, string> | null;
message: string | null;
hasPdf: boolean;
}
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST)
* + FR-13: message คือ commit message ทางเลือก
* + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */
export async function uploadVersion(
documentId: string,
file: Blob,
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string,
message?: string,
pdf?: Blob,
templateId?: string
): Promise<UploadVersionResult> {
if (!authService.isDemoMode()) {
void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token)
const form = new FormData();
form.append("file", file, fileName ?? "document.docx");
// pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF
if (pdf && pdf.size > 0) {
form.append("pdf", pdf, "document.pdf");
}
if (clientVersionId) {
form.append("clientVersionId", clientVersionId);
}
if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields));
}
const trimmedMessage = message?.trim();
if (trimmedMessage) {
form.append("message", trimmedMessage);
}
if (fileName) {
form.append("fileName", fileName);
}
return apiSendExpectJson<UploadVersionResult>(
"/api/documents/versions",
{ method: "POST", body: form },
"อัปโหลด version ไม่สำเร็จ",
);
}
return uploadVersionMock(documentId, file, clientVersionId, fields, message, pdf);
}
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ)
* docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง */
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
if (!authService.isDemoMode()) {
const rows = await apiGetJson<DocumentVersionDTO[]>(
"/api/documents/versions",
"โหลด version history ไม่สำเร็จ",
);
// server เรียงใหม่ → เก่าอยู่แล้ว — แปลงเป็น VersionRecord ตามที่ UI ใช้
return rows.map((row) => ({
versionId: row.versionId,
versionNumber: row.versionNumber,
createdAt: row.createdAt,
createdByUserId: row.createdByUserId,
createdByUserName: row.createdByUserName,
contentHash: row.contentHash,
fields: row.fields && Object.keys(row.fields).length > 0 ? row.fields : null,
message: row.message,
hasPdf: row.hasPdf,
}));
}
return getVersionsMock(documentId);
}
export interface VersionDownloadLink {
url: string;
/** ISO-8601 เวลาที่ URL หมดอายุ */
expiresAt: string;
}
/**
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
* URL เป็น MinIO presigned GET (อายุ env DOCVER_LINK_TTL_SECONDS default 900 วิ) —
* โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้
*/
export async function getDownloadLink(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> {
if (!authService.isDemoMode()) {
void documentId; // เอกสารมาจาก fid ใน token — versionId เช็คคู่กับ docId ฝั่ง server
const query = format && format !== "docx" ? `?format=${encodeURIComponent(format)}` : "";
return apiGetJson<VersionDownloadLink>(
`/api/documents/versions/${encodeURIComponent(versionId)}/download-link${query}`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ",
);
}
return getDownloadLinkMock();
}
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}
// ---------------------------------------------------------------------------
// Mock — เฉพาะโหมด ?addindemo (dev-only)
// ---------------------------------------------------------------------------
interface StoredVersion {
record: VersionRecord;
docxBlob: Blob | null;
@ -78,22 +198,15 @@ async function mockContentHash(file: Blob): Promise<string> {
}
}
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
* + FR-13: message คือ commit message ทางเลือก
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
export async function uploadVersion(
async function uploadVersionMock(
documentId: string,
file: Blob,
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string,
message?: string,
pdf?: Blob,
templateId?: string
pdf?: Blob
): Promise<UploadVersionResult> {
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
void fileName;
void templateId;
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
const versions = store.get(documentId) ?? [];
@ -117,8 +230,7 @@ export async function uploadVersion(
return { hasPdf, versionId: record.versionId, versionNumber };
}
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
async function getVersionsMock(documentId: string): Promise<VersionRecord[]> {
await delay(200);
const versions = store.get(documentId) ?? [];
return versions
@ -127,33 +239,7 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
.map((item) => ({ ...item.record }));
}
export interface VersionDownloadLink {
url: string;
/** ISO-8601 เวลาที่ URL หมดอายุ */
expiresAt: string;
}
/**
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
* mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
* context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
* ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
* — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
*/
export async function getDownloadLink(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> {
void documentId;
void versionId;
void format;
async function getDownloadLinkMock(): Promise<VersionDownloadLink> {
await delay(150);
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
}
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}