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>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-20 10:52:35 +07:00
parent 5917c0532c
commit 765946f0d5
19 changed files with 2711 additions and 185 deletions

273
lib/document-versioning.ts Normal file
View file

@ -0,0 +1,273 @@
// 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}"`;
}

275
lib/docver-store.ts Normal file
View file

@ -0,0 +1,275 @@
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
import { and, desc, eq, sql } from "drizzle-orm";
import { Client } from "minio";
import { db } from "@/db";
import {
documents,
documentVersions,
type DocumentRow,
type DocumentVersionRow,
} from "@/db/schema";
import {
contentDispositionValue,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "@/lib/document-versioning";
type MinioConfig = {
endPoint: string;
port: number;
useSSL: boolean;
accessKey: string;
secretKey: string;
bucket: string;
ttlSeconds: number;
};
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
function readConfig(): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT?.trim();
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
if (!endPoint || !accessKey || !secretKey) {
throw new Error(
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
);
}
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
return {
endPoint,
port: Number.isFinite(port) && port > 0 ? port : 9000,
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
accessKey,
secretKey,
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
ttlSeconds:
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
};
}
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
const globalForMinio = globalThis as unknown as {
docverClient?: { key: string; client: Client };
docverBucketReady?: { bucket: string; promise: Promise<void> };
};
function getClient(): { client: Client; config: MinioConfig } {
const config = readConfig();
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
if (globalForMinio.docverClient?.key !== key) {
globalForMinio.docverClient = {
key,
client: new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
}),
};
}
return { client: globalForMinio.docverClient.client, config };
}
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
async function ensureBucket(client: Client, bucket: string): Promise<void> {
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
const promise = (async () => {
if (await client.bucketExists(bucket)) return;
try {
await client.makeBucket(bucket);
} catch (err) {
if (!(await client.bucketExists(bucket))) throw err;
}
})();
globalForMinio.docverBucketReady = { bucket, promise };
promise.catch(() => {
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
if (globalForMinio.docverBucketReady?.promise === promise) {
globalForMinio.docverBucketReady = undefined;
}
});
}
return globalForMinio.docverBucketReady.promise;
}
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
return {
docx: `docver/${versionId}/document.docx`,
pdf: `docver/${versionId}/document.pdf`,
};
}
export type PutVersionInput = {
versionId: string;
docx: { bytes: Buffer; fileName: string | null };
pdf?: { bytes: Buffer; fileName: string | null };
};
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
const { client, config } = getClient();
await ensureBucket(client, config.bucket);
const keys = versionObjectKeys(input.versionId);
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
"Content-Type": DOCX_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
});
if (!input.pdf) return;
try {
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
"Content-Type": PDF_CONTENT_TYPE,
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
});
} catch (err) {
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
throw err;
}
}
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
export async function removeVersionObjects(
versionId: string,
hadPdf: boolean,
): Promise<void> {
try {
const { client, config } = getClient();
const keys = versionObjectKeys(versionId);
await client.removeObject(config.bucket, keys.docx);
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
} catch (err) {
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
}
}
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
* คำนวณ local ล้วนไม่มี network call */
export async function presignVersionUrl(
versionId: string,
format: "docx" | "pdf",
): Promise<{ url: string; expiresAt: string }> {
const { client, config } = getClient();
const key = versionObjectKeys(versionId)[format];
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
return {
url,
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
};
}
// ─── DB ──────────────────────────────────────────────────────────────────────
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
return row ?? null;
}
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
return db
.select()
.from(documentVersions)
.where(eq(documentVersions.documentId, documentId))
.orderBy(desc(documentVersions.versionNumber));
}
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
export async function findVersionForDoc(
docId: string,
versionId: string,
): Promise<DocumentVersionRow | null> {
const [row] = await db
.select({ version: documentVersions })
.from(documentVersions)
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
.limit(1);
return row?.version ?? null;
}
export type InsertVersionInput = {
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
versionId: string;
docId: string;
clientVersionId: string | null;
contentHash: string;
message: string | null;
fields: Record<string, string> | null;
templateId: string | null;
fileName: string | null;
docxObjectKey: string;
pdfObjectKey: string | null;
docxSize: number;
pdfSize: number | null;
userId: string;
userName: string;
};
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
return db.transaction(async (tx) => {
await tx
.insert(documents)
.values({ docId: input.docId })
.onConflictDoNothing({ target: documents.docId });
const [doc] = await tx
.select({ id: documents.id })
.from(documents)
.where(eq(documents.docId, input.docId))
.for("update");
if (!doc) {
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
}
const [latest] = await tx
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
.from(documentVersions)
.where(eq(documentVersions.documentId, doc.id));
const [row] = await tx
.insert(documentVersions)
.values({
id: input.versionId,
documentId: doc.id,
versionNumber: (latest?.max ?? 0) + 1,
clientVersionId: input.clientVersionId,
contentHash: input.contentHash,
message: input.message,
fields: input.fields,
templateId: input.templateId,
fileName: input.fileName,
docxObjectKey: input.docxObjectKey,
pdfObjectKey: input.pdfObjectKey,
docxSize: input.docxSize,
pdfSize: input.pdfSize,
userId: input.userId,
userName: input.userName,
})
.returning();
return row!;
});
}

View file

@ -1,5 +1,6 @@
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
// (จัด version เอกสาร)
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
//
@ -23,8 +24,8 @@ export const openApiSpec = {
title: "Document Template & External API ของระบบ Judge's Note",
version: "1.0.0",
description:
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control) " +
"และ API ภายนอกสำหรับ backend e-Court (N10)",
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
},
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
tags: [
@ -34,6 +35,13 @@ export const openApiSpec = {
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
},
{
name: "documents",
description:
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — ไม่มี auth (stand-alone): " +
"ผู้เรียกบอก userId/userName เองใน multipart",
},
{
name: "Judge's Note",
description:
@ -217,6 +225,193 @@ export const openApiSpec = {
},
},
},
"/api/documents/{docId}/versions": {
post: {
tags: ["documents"],
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
description:
"สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " +
".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " +
"คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " +
"optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response)",
parameters: [
{
name: "docId",
in: "path",
required: true,
schema: { type: "string" },
description: "รหัสเอกสารจากผู้เรียก (add-in GUID / รหัส e-Court) — [A-Za-z0-9._:-]",
},
],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
required: ["file", "userId", "userName"],
properties: {
file: {
type: "string",
format: "binary",
description: "ไฟล์ .docx ของ version นี้ (บังคับ)",
},
pdf: {
type: "string",
format: "binary",
description:
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
},
userId: {
type: "string",
description: "รหัสผู้อัปโหลด (ระบบ stand-alone ไม่มี session)",
},
userName: {
type: "string",
description: "ชื่อผู้อัปโหลด (แสดงใน version history)",
},
clientVersionId: {
type: "string",
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
},
fields: {
type: "string",
description:
'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}',
},
message: {
type: "string",
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
},
templateId: {
type: "string",
description: "รหัส template ที่ใช้สร้างเอกสาร (ถ้ามี)",
},
fileName: {
type: "string",
description:
"ชื่อไฟล์สำหรับแสดงตอนดาวน์โหลด (ไม่ส่ง = ใช้ชื่อไฟล์ที่แนบ)",
},
},
},
},
},
},
responses: {
200: {
description: "version ถูกบันทึกแล้ว",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UploadVersionResult" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
413: {
description: "ไฟล์แนบใหญ่เกิน 100MB",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
get: {
tags: ["documents"],
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
description:
"version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " +
"message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in",
parameters: [
{
name: "docId",
in: "path",
required: true,
schema: { type: "string" },
description: "รหัสเอกสารจากผู้เรียก — [A-Za-z0-9._:-]",
},
],
responses: {
200: {
description: "รายการ version เรียงใหม่ → เก่า",
content: {
"application/json": {
schema: {
type: "array",
items: { $ref: "#/components/schemas/VersionRecord" },
},
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
404: {
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/{docId}/versions/{versionId}/download-link": {
get: {
tags: ["documents"],
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
description:
"คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " +
"เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " +
"ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404",
parameters: [
{
name: "docId",
in: "path",
required: true,
schema: { type: "string" },
description: "รหัสเอกสาร (ต้องตรงกับ version นี้ — โยน id ข้ามเอกสารไม่ได้)",
},
{
name: "versionId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "id ของ version (field versionId ใน VersionRecord)",
},
{
name: "format",
in: "query",
required: false,
schema: { type: "string", enum: ["docx", "pdf"], default: "docx" },
description: "ชนิดไฟล์ที่จะดาวน์โหลด (ไม่ส่ง = docx)",
},
],
responses: {
200: {
description: "presigned URL พร้อมเวลาหมดอายุ",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/DownloadLink" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
404: {
description:
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
},
components: {
securitySchemes: {
@ -407,6 +602,48 @@ export const openApiSpec = {
createdAt: { type: "string", format: "date-time" },
},
},
UploadVersionResult: {
type: "object",
description: "ตอบกลับหลังอัปโหลด version สำเร็จ",
properties: {
hasPdf: { type: "boolean", description: "true = version นี้มีสำเนา PDF" },
versionId: { type: "string", format: "uuid" },
versionNumber: {
type: "integer",
description: "เลข version ที่ server จ่าย (ค่าจริง — ไม่ใช่ที่ client คาด)",
},
},
},
VersionRecord: {
type: "object",
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
properties: {
versionId: { type: "string", format: "uuid" },
versionNumber: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
createdByUserId: { type: "string" },
createdByUserName: { type: "string" },
contentHash: {
type: "string",
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
},
fields: {
type: "object",
nullable: true,
description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)",
additionalProperties: { type: "string" },
},
message: { type: "string", nullable: true, description: "commit message" },
hasPdf: { type: "boolean" },
},
},
DownloadLink: {
type: "object",
properties: {
url: { type: "string", description: "presigned URL ของ MinIO (GET โดยตรง)" },
expiresAt: { type: "string", format: "date-time" },
},
},
},
},
} as const;