// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions // กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่ // POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ // Authorization: Bearer ทุก request — identity (userid/username), templateId และ // docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด // ไม่รับจาก URL/form อีก (lib/docver-token.ts) import { createHash, randomUUID } from "node:crypto"; import { toDocumentVersionDTO } from "@/db/schema"; import { docverError, DOCVER_MAX_UPLOAD_BYTES, DOCX_CONTENT_TYPE, isDocxBytes, isPdfBytes, parseVersionUploadForm, sanitizeFilePartName, } from "@/lib/document-versioning"; import { requireDocverBearer } from "@/lib/docver-token"; import { findDocumentByDocId, insertVersionRecord, listVersionsByDocId, putVersionObjects, removeVersionObjects, versionObjectKeys, } from "@/lib/docver-store"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) * ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ export async function POST(request: Request) { const auth = requireDocverBearer(request); if (!auth.ok) return auth.response; // docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify const docId = auth.claims.fid; let form: FormData; try { form = await request.formData(); } catch { return docverError("body ต้องเป็น multipart/form-data", 400); } const parsed = parseVersionUploadForm(form); if (!parsed.ok) return docverError(parsed.error, 400); const file = form.get("file"); if (!(file instanceof File)) { return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400); } if (file.size === 0) { return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400); } // route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer if (file.size > DOCVER_MAX_UPLOAD_BYTES) { return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413); } if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) { return docverError('field "file" ต้องเป็นไฟล์ .docx', 400); } // add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort) const rawPdf = form.get("pdf"); let pdf: File | null = null; if (rawPdf instanceof File && rawPdf.size > 0) { if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) { return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413); } pdf = rawPdf; } let docxBytes: Buffer; let pdfBytes: Buffer | null = null; try { docxBytes = Buffer.from(await file.arrayBuffer()); pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null; } catch (err) { console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err); return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); } if (!isDocxBytes(docxBytes)) { return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400); } if (pdfBytes && !isPdfBytes(pdfBytes)) { return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400); } const contentHash = createHash("sha256").update(docxBytes).digest("hex"); const versionId = randomUUID(); // ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) → // ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1) const docxDisplayName = parsed.form.fileName ?? sanitizeFilePartName(file.name); try { await putVersionObjects({ versionId, docx: { bytes: docxBytes, fileName: docxDisplayName }, pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined, }); } catch (err) { console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err); return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); } const keys = versionObjectKeys(versionId); let row; try { row = await insertVersionRecord({ versionId, docId, clientVersionId: parsed.form.clientVersionId, contentHash, message: parsed.form.message, fields: parsed.form.fields, templateId: auth.claims.template, fileName: docxDisplayName, docxObjectKey: keys.docx, pdfObjectKey: pdfBytes ? keys.pdf : null, docxSize: docxBytes.length, pdfSize: pdfBytes?.length ?? null, userId: auth.claims.userid, userName: auth.claims.username, }); } catch (err) { // DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้ console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err); await removeVersionObjects(versionId, pdfBytes !== null); return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); } return new Response( JSON.stringify({ hasPdf: pdfBytes !== null, versionId: row.id, versionNumber: row.versionNumber, }), { headers: { "content-type": "application/json; charset=utf-8" } }, ); } /** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404 * (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */ export async function GET(request: Request) { const auth = requireDocverBearer(request); if (!auth.ok) return auth.response; const docId = auth.claims.fid; try { const doc = await findDocumentByDocId(docId); if (!doc) { return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); } const rows = await listVersionsByDocId(doc.id); return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), { headers: { "content-type": "application/json; charset=utf-8" }, }); } catch (err) { console.error("docver: โหลด version history ล้ม", err); return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); } }