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>
2026-10-20 10:52:35 +07:00
|
|
|
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions
|
2026-10-26 06:29:19 +07:00
|
|
|
// กลุ่ม 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
|
|
|
|
|
// แกะจาก claims ของ token ไม่รับจาก multipart form อีก (lib/docver-token.ts)
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
|
|
|
|
|
|
import { toDocumentVersionDTO } from "@/db/schema";
|
|
|
|
|
import {
|
|
|
|
|
docverError,
|
|
|
|
|
DOCVER_MAX_UPLOAD_BYTES,
|
|
|
|
|
DOCX_CONTENT_TYPE,
|
|
|
|
|
isDocxBytes,
|
|
|
|
|
isPdfBytes,
|
|
|
|
|
parseDocId,
|
|
|
|
|
parseVersionUploadForm,
|
|
|
|
|
sanitizeFilePartName,
|
|
|
|
|
} from "@/lib/document-versioning";
|
2026-10-26 06:29:19 +07:00
|
|
|
import { requireDocverBearer } from "@/lib/docver-token";
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
import {
|
|
|
|
|
findDocumentByDocId,
|
|
|
|
|
insertVersionRecord,
|
|
|
|
|
listVersionsByDocId,
|
|
|
|
|
putVersionObjects,
|
|
|
|
|
removeVersionObjects,
|
|
|
|
|
versionObjectKeys,
|
|
|
|
|
} from "@/lib/docver-store";
|
|
|
|
|
|
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
2026-10-26 06:29:19 +07:00
|
|
|
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
|
|
|
|
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
|
|
|
|
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
|
|
|
|
export async function POST(request: Request, { params }: { params: { docId: string } }) {
|
2026-10-26 06:29:19 +07:00
|
|
|
const auth = requireDocverBearer(request);
|
|
|
|
|
if (!auth.ok) return auth.response;
|
|
|
|
|
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
const docId = parseDocId(params.docId);
|
|
|
|
|
if (!docId.ok) return docverError(docId.error, 400);
|
|
|
|
|
|
|
|
|
|
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: docId.docId,
|
|
|
|
|
clientVersionId: parsed.form.clientVersionId,
|
|
|
|
|
contentHash,
|
|
|
|
|
message: parsed.form.message,
|
|
|
|
|
fields: parsed.form.fields,
|
2026-10-26 06:29:19 +07:00
|
|
|
templateId: auth.claims.template,
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
fileName: docxDisplayName,
|
|
|
|
|
docxObjectKey: keys.docx,
|
|
|
|
|
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
|
|
|
|
docxSize: docxBytes.length,
|
|
|
|
|
pdfSize: pdfBytes?.length ?? null,
|
2026-10-26 06:29:19 +07:00
|
|
|
userId: auth.claims.userid,
|
|
|
|
|
userName: auth.claims.username,
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
});
|
|
|
|
|
} 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 + ปุ่มลองใหม่ ไม่พัง) */
|
2026-10-26 06:29:19 +07:00
|
|
|
export async function GET(request: Request, { params }: { params: { docId: string } }) {
|
|
|
|
|
const auth = requireDocverBearer(request);
|
|
|
|
|
if (!auth.ok) return auth.response;
|
|
|
|
|
|
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>
2026-10-20 10:52:35 +07:00
|
|
|
const docId = parseDocId(params.docId);
|
|
|
|
|
if (!docId.ok) return docverError(docId.error, 400);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const doc = await findDocumentByDocId(docId.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);
|
|
|
|
|
}
|
|
|
|
|
}
|