fix(docver): drop fields value limit + treat history 404 as empty in add-in
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 1m28s
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 0s
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 1m28s
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 0s
Two fixes from first real-Word testing of the unmocked versioning flow: - fields value length: remove the limit entirely (was 10,000, briefly 100,000) — real court documents run 100-200 pages with content controls several hundred thousand chars long; callers are Bearer-only and the column is Postgres jsonb (up to ~255MB), so no storage bottleneck. Structural guards stay: ≤1,000 keys (was 100), key ≤200 chars, value must be string. - getVersions in versioningService now maps the history 404 to an empty list: files stamped SavedByAddIn back in the mock era enter the tracking phase before any real upload exists, so the pane shows "ยังไม่มี version ในระบบ" and Save & Version creates v1 (implicit document creation) instead of surfacing a 404 error banner. Other errors still propagate. Tests: 288/288 (new cases — 1M-char value passes, key cap 1,000/1,001, history 404 -> []). E2E against MinIO: 50k and 1M-char Thai field values upload 200 and roundtrip byte-exact through jsonb. Docs synced: PLAN-DOCVER §1, CLAUDE.md. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
37b18c58c2
commit
b8ff622999
6 changed files with 52 additions and 13 deletions
|
|
@ -51,7 +51,7 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow
|
|||
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
||||
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param)
|
||||
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param)
|
||||
lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env)
|
||||
lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid)
|
||||
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ multipart/form-data:
|
|||
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
||||
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
||||
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
|
||||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) |
|
||||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) |
|
||||
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
|
||||
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
|
||||
|
||||
|
|
|
|||
|
|
@ -124,8 +124,11 @@ export function parseFieldsJson(raw: string):
|
|||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
if (keys.length > 100) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
|
||||
// เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
|
||||
// เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
|
||||
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
|
||||
if (keys.length > 1_000) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
|
|
@ -135,9 +138,6 @@ export function parseFieldsJson(raw: string):
|
|||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
if (value.length > 10_000) {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,25 @@ describe("parseVersionUploadForm", () => {
|
|||
form.append("fields", '{"a":1}');
|
||||
expect(parseVersionUploadForm(form).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => {
|
||||
const big = baseForm();
|
||||
big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) }));
|
||||
expect(parseVersionUploadForm(big).ok).toBe(true);
|
||||
|
||||
const manyKeys = baseForm();
|
||||
manyKeys.append(
|
||||
"fields",
|
||||
JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))),
|
||||
);
|
||||
expect(parseVersionUploadForm(manyKeys).ok).toBe(true);
|
||||
const over = baseForm();
|
||||
over.append(
|
||||
"fields",
|
||||
JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))),
|
||||
);
|
||||
expect(parseVersionUploadForm(over).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,18 @@ describe("versioningService (real mode)", () => {
|
|||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 401 })));
|
||||
await expect(getVersions("d")).rejects.toMatchObject({ name: "AuthRequiredError" });
|
||||
});
|
||||
|
||||
it("404 จาก history (เอกสารยังไม่เคยมี version เช่น stamp ค้างสมัย mock) = list ว่าง ไม่ใช่ error", async () => {
|
||||
await signInReal();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async (_url: string, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ error: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)" }), { status: 404 }),
|
||||
),
|
||||
);
|
||||
await expect(getVersions("d")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// โหมด demo ไว้ท้ายสุด — signInForDemo เปิด flag ค้างใน module จนกว่าจะ sign-in จริง
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
import * as authService from "./authService";
|
||||
import { generateGuid } from "./metadataService";
|
||||
import { apiGetJson, apiSendExpectJson } from "./apiClient";
|
||||
import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient";
|
||||
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -81,13 +81,21 @@ export async function uploadVersion(
|
|||
}
|
||||
|
||||
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ)
|
||||
* docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง */
|
||||
* docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง
|
||||
* 404 = เอกสารนี้ยังไม่เคยมี version (เช่น ไฟล์ที่ stamp SavedByAddIn ไว้สมัย mock แล้วเพิ่ง
|
||||
* มาใช้ backend จริง) — สำหรับ add-in ความหมายเดียวกับ list ว่าง: UI แสดง "ยังไม่มี version
|
||||
* ในระบบ" และกด Save & Version สร้าง v1 ได้เลย (upload สร้างเอกสารให้อัตโนมัติ) */
|
||||
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||
if (!authService.isDemoMode()) {
|
||||
const rows = await apiGetJson<DocumentVersionDTO[]>(
|
||||
"/api/documents/versions",
|
||||
"โหลด version history ไม่สำเร็จ",
|
||||
);
|
||||
let rows: DocumentVersionDTO[];
|
||||
try {
|
||||
rows = await apiGetJson<DocumentVersionDTO[]>("/api/documents/versions", "โหลด version history ไม่สำเร็จ");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// server เรียงใหม่ → เก่าอยู่แล้ว — แปลงเป็น VersionRecord ตามที่ UI ใช้
|
||||
return rows.map((row) => ({
|
||||
versionId: row.versionId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue