feat(docver): derive docId from fid claim and drop [docId] URL segment
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m9s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

- docId of every docver operation now comes from the Bearer token: the fid
  claim (e-Court fileId) is used directly as docId; segment [docId] removed
  from URLs — POST/GET /api/documents/versions and
  GET /api/documents/versions/{versionId}/download-link
- fid is required and must be a string (trim 1-200): token issuance rejects
  missing/blank/non-string fid with 400, verify rejects fid-less tokens with 401
- parseDocId removed from lib/document-versioning.ts (dead code, no callers)
- openapi spec: paths renamed, docId path params removed, fid required + string
- PLAN-DOCVER.md / CLAUDE.md / wordaddin versioningService comment updated
- tests: fid required/string cases added, parseDocId describe removed (256 pass)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-26 10:05:01 +07:00
parent aad6f155fe
commit 0b2c9e6343
10 changed files with 122 additions and 158 deletions

View file

@ -0,0 +1,52 @@
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
import {
docverError,
parseDownloadFormat,
parseVersionIdParam,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { versionId: string } },
) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
const docId = auth.claims.fid;
const versionId = parseVersionIdParam(params.versionId);
if (!versionId.ok) return docverError(versionId.error, 400);
let format: string | null = null;
try {
format = new URL(request.url).searchParams.get("format");
} catch {
return docverError("URL ของ request ไม่ถูกต้อง", 400);
}
const parsedFormat = parseDownloadFormat(format);
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
try {
const row = await findVersionForDoc(docId, versionId.versionId);
if (!row) {
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
}
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
return docverError("version นี้ไม่มีสำเนา PDF", 404);
}
const link = await presignVersionUrl(row.id, parsedFormat.format);
return new Response(JSON.stringify(link), {
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("docver: สร้าง download link ล้ม", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -0,0 +1,170 @@
// 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);
}
}