// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only) // - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500 // - object key เป็น uuid ล้วน `docver//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 }; }; 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 { 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 { 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 { 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 { 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 { 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 { 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 | 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 { 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`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!; }); }