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>
275 lines
13 KiB
TypeScript
275 lines
13 KiB
TypeScript
// 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!;
|
|
});
|
|
}
|