/** * 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 ตามต้นฉบับ * 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 | 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, fileName?: string, message?: string, pdf?: Blob, templateId?: string ): Promise { 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( "/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 { if (!authService.isDemoMode()) { const rows = await apiGetJson( "/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 { if (!authService.isDemoMode()) { void documentId; // เอกสารมาจาก fid ใน token — versionId เช็คคู่กับ docId ฝั่ง server const query = format && format !== "docx" ? `?format=${encodeURIComponent(format)}` : ""; return apiGetJson( `/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; pdfBlob: Blob | null; } /** key = documentId, เก็บเรียงเก่า → ใหม่ */ const store = new Map(); const STORAGE_KEY = "wordaddin-mock-versions"; /** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */ function loadPersisted(): Record { try { if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage const raw = window.localStorage.getItem(STORAGE_KEY); const parsed = raw ? (JSON.parse(raw) as Record) : {}; return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } const persisted = loadPersisted(); for (const [documentId, records] of Object.entries(persisted)) { if (Array.isArray(records)) { store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null }))); } } /** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */ function persist(): void { try { const snapshot: Record = {}; store.forEach((versions, documentId) => { snapshot[documentId] = versions.map((item) => item.record); }); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { // ignore — quota/private mode } } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */ async function mockContentHash(file: Blob): Promise { try { const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer()); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } catch { return "mock-hash"; } } async function uploadVersionMock( documentId: string, file: Blob, clientVersionId: string, fields?: Record, message?: string, pdf?: Blob ): Promise { void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง const versions = store.get(documentId) ?? []; const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1; const hasPdf = !!pdf && pdf.size > 0; const trimmedMessage = message?.trim(); const record: VersionRecord = { versionId: generateGuid(), versionNumber, createdAt: new Date().toISOString(), createdByUserId: authService.getUsername() ?? "unknown", contentHash: await mockContentHash(file), message: trimmedMessage || null, hasPdf, fields: fields && Object.keys(fields).length > 0 ? fields : null, }; versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null }); store.set(documentId, versions); persist(); return { hasPdf, versionId: record.versionId, versionNumber }; } async function getVersionsMock(documentId: string): Promise { await delay(200); const versions = store.get(documentId) ?? []; return versions .slice() .reverse() .map((item) => ({ ...item.record })); } async function getDownloadLinkMock(): Promise { await delay(150); return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() }; }