eCourt-note/lib/document-versioning.ts
Suphonchai Phoonsawat 765946f0d5 feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).

- POST versions (multipart): validates fields/magic bytes/100MB cap,
  uploads to MinIO first, cleans up objects if the DB insert fails;
  returns {hasPdf, versionId, versionNumber} with server-authoritative
  numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
  (docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
  host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)

wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00

273 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
import { decodeUploadFilename } from "@/utils/upload-name";
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
export const DOCVER_MESSAGE_MAX = 2048;
export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const PDF_CONTENT_TYPE = "application/pdf";
/** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก
* ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */
const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ */
const FORM_FIELDS = [
"file",
"pdf",
"userId",
"userName",
"clientVersionId",
"fields",
"message",
"templateId",
"fileName",
] as const;
export type DocverError = { ok: false; error: string };
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
export function docverError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน
* (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */
export function parseDocId(raw: string):
| { ok: true; docId: string }
| DocverError {
const docId = raw.trim();
if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) {
return {
ok: false,
error:
"รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -",
};
}
return { ok: true, docId };
}
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
export function isDocxBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
}
export function isPdfBytes(bytes: Buffer): boolean {
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
}
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
export function parseVersionIdParam(raw: string):
| { ok: true; versionId: string }
| DocverError {
const versionId = raw.trim().toLowerCase();
if (!UUID_RE.test(versionId)) {
return { ok: false, error: "versionId ต้องเป็น UUID" };
}
return { ok: true, versionId };
}
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
export function parseDownloadFormat(raw: string | null):
| { ok: true; format: "docx" | "pdf" }
| DocverError {
const format = raw?.trim() ?? "";
if (format === "") return { ok: true, format: "docx" };
if (format === "docx" || format === "pdf") return { ok: true, format };
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
}
function trimOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
function formString(form: FormData, field: string):
| { ok: true; value: string | null }
| DocverError {
const entry = form.get(field);
if (entry === null) return { ok: true, value: null };
if (typeof entry !== "string") {
return { ok: false, error: `field '${field}' ต้องเป็น string` };
}
return { ok: true, value: trimOrNull(entry) };
}
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
export function sanitizeDisplayFileName(raw: string | null): string | null {
if (raw === null) return null;
const base = raw.split(/[\\/]/).pop() ?? raw;
return trimOrNull(base)?.slice(0, 200) ?? null;
}
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
export function sanitizeFilePartName(raw: string): string | null {
return sanitizeDisplayFileName(decodeUploadFilename(raw));
}
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
export function parseFieldsJson(raw: string):
| { ok: true; fields: Record<string, string> | null }
| DocverError {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
}
const record = parsed as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length > 100) {
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
}
for (const key of keys) {
if (key.length > 200) {
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
}
const value = record[key];
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>) };
}
export type ParsedVersionUploadForm = {
userId: string;
userName: string;
clientVersionId: string | null;
message: string | null;
templateId: string | null;
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
fileName: string | null;
fields: Record<string, string> | null;
};
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
* size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */
export function parseVersionUploadForm(form: FormData):
| { ok: true; form: ParsedVersionUploadForm }
| DocverError {
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
// downlevelIteration — array ธรรมดาไม่ต้อง)
for (const key of Array.from(form.keys())) {
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
};
}
}
const userId = formString(form, "userId");
if (!userId.ok) return userId;
if (userId.value === null) {
return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
}
if (userId.value.length > 100) {
return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" };
}
const userName = formString(form, "userName");
if (!userName.ok) return userName;
if (userName.value === null) {
return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
}
if (userName.value.length > 200) {
return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" };
}
const clientVersionId = formString(form, "clientVersionId");
if (!clientVersionId.ok) return clientVersionId;
if (clientVersionId.value && clientVersionId.value.length > 100) {
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
}
const templateId = formString(form, "templateId");
if (!templateId.ok) return templateId;
if (templateId.value && templateId.value.length > 100) {
return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" };
}
const message = formString(form, "message");
if (!message.ok) return message;
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
}
const rawFields = formString(form, "fields");
if (!rawFields.ok) return rawFields;
let fields: Record<string, string> | null = null;
if (rawFields.value !== null) {
const parsed = parseFieldsJson(rawFields.value);
if (!parsed.ok) return parsed;
fields = parsed.fields;
}
const rawFileName = formString(form, "fileName");
if (!rawFileName.ok) return rawFileName;
return {
ok: true,
form: {
userId: userId.value,
userName: userName.value,
clientVersionId: clientVersionId.value,
message: message.value,
templateId: templateId.value,
fileName: sanitizeDisplayFileName(rawFileName.value),
fields,
},
};
}
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
const ext = `.${kind}`;
const ascii = (displayName ?? "")
.replace(/[^\x20-\x7E]/g, "")
.replace(/["\\]/g, "")
.replace(/\s+/g, " ")
.trim();
let asciiName: string = DEFAULT_FILE_NAMES[kind];
if (/[A-Za-z0-9]/.test(ascii)) {
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
}
const display = displayName?.trim();
if (display) {
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
}
return `attachment; filename="${asciiName}"`;
}