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:
parent
5917c0532c
commit
765946f0d5
19 changed files with 2711 additions and 185 deletions
273
lib/document-versioning.ts
Normal file
273
lib/document-versioning.ts
Normal 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
275
lib/docver-store.ts
Normal 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!;
|
||||
});
|
||||
}
|
||||
243
lib/openapi.ts
243
lib/openapi.ts
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue