Refactor document versioning to extract fields from uploaded .docx files on the server side
- Removed the `fields` parameter from the version upload form, as it is now extracted from the uploaded .docx file using the `extractContentControls` function. - Updated related types and interfaces to reflect the new structure of version fields. - Modified the OpenAPI specification to document the changes in the versioning process. - Adjusted tests to ensure compliance with the new behavior, including validation for the absence of the `fields` parameter. - Implemented a new function `extractDocumentSnapshot` to handle the extraction of content controls and document properties from .docx files. - Updated metadata and versioning services to align with the new extraction logic.
This commit is contained in:
parent
bc61f0fd2e
commit
cdae1ceedd
17 changed files with 380 additions and 179 deletions
|
|
@ -25,12 +25,13 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||
|
||||
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
* ผ่าน lib/docver-token.ts · `fields` ถอดออก 2026-10-30 — server แกะจากไฟล์ .docx
|
||||
* ด้วย extractContentControls ของ lib/lib-tmpl.ts ตัวเดียวกับ /api/template/extract
|
||||
* ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
const FORM_FIELDS = [
|
||||
"file",
|
||||
"pdf",
|
||||
"clientVersionId",
|
||||
"fields",
|
||||
"message",
|
||||
"fileName",
|
||||
] as const;
|
||||
|
|
@ -108,39 +109,10 @@ export function sanitizeFilePartName(raw: string): string | null {
|
|||
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||
}
|
||||
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||
export function parseFieldsJson(raw: string):
|
||||
| { ok: true; fields: Record<string, string> | null }
|
||||
| DocverError {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
// เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
|
||||
// เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
|
||||
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
|
||||
if (keys.length > 1_000) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — ถอดออกแล้ว
|
||||
* (2026-10-30): form field "fields" ยกเลิก ไม่รับจากผู้เรียกอีก — fields ของ version
|
||||
* แกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server ด้วย extractContentControls ของ
|
||||
* lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract) ที่ route ของ POST versions */
|
||||
|
||||
export type ParsedVersionUploadForm = {
|
||||
clientVersionId: string | null;
|
||||
|
|
@ -148,7 +120,6 @@ export type ParsedVersionUploadForm = {
|
|||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||
fileName: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||
|
|
@ -180,15 +151,6 @@ export function parseVersionUploadForm(form: FormData):
|
|||
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||
}
|
||||
|
||||
const rawFields = formString(form, "fields");
|
||||
if (!rawFields.ok) return rawFields;
|
||||
let fields: Record<string, string> | null = null;
|
||||
if (rawFields.value !== null) {
|
||||
const parsed = parseFieldsJson(rawFields.value);
|
||||
if (!parsed.ok) return parsed;
|
||||
fields = parsed.fields;
|
||||
}
|
||||
|
||||
const rawFileName = formString(form, "fileName");
|
||||
if (!rawFileName.ok) return rawFileName;
|
||||
|
||||
|
|
@ -198,7 +160,6 @@ export function parseVersionUploadForm(form: FormData):
|
|||
clientVersionId: clientVersionId.value,
|
||||
message: message.value,
|
||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||
fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
documents,
|
||||
documentVersions,
|
||||
type DocumentRow,
|
||||
type DocumentVersionFields,
|
||||
type DocumentVersionRow,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
|
|
@ -229,7 +230,8 @@ export type InsertVersionInput = {
|
|||
clientVersionId: string | null;
|
||||
contentHash: string;
|
||||
message: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
/** ก้อนเดียวกับ return ของ /api/template/extract (ดู DocumentVersionFields ใน db/schema.ts) */
|
||||
fields: DocumentVersionFields | null;
|
||||
templateId: string | null;
|
||||
fileName: string | null;
|
||||
docxObjectKey: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils';
|
||||
import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils';
|
||||
import createReport from 'docx-templates';
|
||||
import PizzaZip from "pizzip";
|
||||
import {
|
||||
|
|
@ -1217,6 +1217,71 @@ export function getDocProps(
|
|||
}
|
||||
|
||||
|
||||
//**************************** document snapshot *******************************************
|
||||
|
||||
/** DocPropsResult หลังทำ JSON-safe (Date → ISO string) — ชนิดของ `properties` ใน
|
||||
* DocumentExtractSnapshot ทั้งฝั่ง response ของ /api/template/extract และ column
|
||||
* `fields` jsonb ของ document_versions (ค่าข้าม server→client เป็น string เสมอ) */
|
||||
export interface DocPropsJson {
|
||||
standard?: Record<string, string | undefined>;
|
||||
extended?: Record<string, number | string | undefined>;
|
||||
custom?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
/** รูป return ของ /api/template/extract — ใช้ร่วม 2 ที่: route นั้นคืนทั้งก้อนตรง ๆ
|
||||
* และ POST /api/documents/versions เก็บทั้งก้อนเป็น fields ของ version (2026-10-30) */
|
||||
export interface DocumentExtractSnapshot {
|
||||
/** ค่า Content Control `{ [tag]: text }` */
|
||||
cc: ExtractedData;
|
||||
/** ข้อความใน bookmark `{ [name]: text }` */
|
||||
bookmark: ExtractedData;
|
||||
/** document properties (standard/extended/custom) */
|
||||
properties: DocPropsJson;
|
||||
/** ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (ฟอร์แมตไทย) + HmacSha256/Ed25519 เฉพาะเมื่อ
|
||||
* ผู้เรียกส่ง secret/publicKeyHex มาให้ใช้ตรวจ */
|
||||
verify: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** แกะข้อมูลจาก docx ทั้งชุด `{cc, bookmark, properties, verify}` — logic ย้ายมาจาก
|
||||
* app/api/template/extract/route.ts แบบเดิม (แถวต่อแถว) เพื่อให้ return ของ extract
|
||||
* กับ fields ของ version ที่สร้างใหม่ตรงกันเป๊ะโดยการก่อสร้าง
|
||||
* · secret/publicKeyHex = ของผู้เรียกเพื่อ "ทดสอบ verify" (extract) หรือ secret ของ
|
||||
* add-in ticket ฝั่ง server (ตอนสร้าง version) — ไม่ส่ง = ไม่มี key นั้นใน verify
|
||||
* · ticket_data ที่ไม่ใช่ JSON จะ throw ผู้เรียกจัดการเอง (extract = 500 / versions = 400) */
|
||||
export function extractDocumentSnapshot(
|
||||
docxBuffer: Buffer,
|
||||
options: { secret?: string; publicKeyHex?: string } = {},
|
||||
): DocumentExtractSnapshot {
|
||||
const cc = extractContentControls(docxBuffer);
|
||||
const bookmark = extractBookmark(docxBuffer);
|
||||
// JSON-safe ทันที (Date → ISO string): response ของ extract กับ jsonb ของ version ได้
|
||||
// ค่าชนิดเดียวกัน (NextResponse.json / drizzle serialize Date เป็น ISO อยู่แล้ว —
|
||||
// ผลลัพธ์ JSON ไม่ต่างจากเดิม เปลี่ยนแค่ชนิดฝั่ง TS)
|
||||
const properties = JSON.parse(JSON.stringify(getDocProps(docxBuffer))) as DocPropsJson;
|
||||
|
||||
const verify: Record<string, unknown> = {};
|
||||
const ticket_data = properties.custom?.ticket_data;
|
||||
const HmacSha256 = String(properties.custom?.HmacSha256);
|
||||
const Ed25519 = String(properties.custom?.Ed25519);
|
||||
|
||||
if (ticket_data && typeof ticket_data === "string") {
|
||||
const ticketDataObj = JSON.parse(ticket_data);
|
||||
const unix_timestamp = Number(ticketDataObj.exp) * 1000;
|
||||
if (unix_timestamp) {
|
||||
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" });
|
||||
}
|
||||
if (options.secret && HmacSha256) {
|
||||
verify['HmacSha256'] = verifyHmacSha256(ticket_data, options.secret, HmacSha256);
|
||||
}
|
||||
if (options.publicKeyHex && Ed25519) {
|
||||
verify["Ed25519"] = verifyEd25519(ticket_data, Ed25519, options.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
return { cc, bookmark, properties, verify };
|
||||
}
|
||||
|
||||
|
||||
//**************************** dynamic content *******************************************
|
||||
// sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง)
|
||||
type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
|
||||
|
|
|
|||
|
|
@ -705,7 +705,11 @@ export const openApiSpec = {
|
|||
"- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " +
|
||||
"หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" +
|
||||
"- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " +
|
||||
"**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**",
|
||||
"**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**\n" +
|
||||
"- `fields` ของ version ถูก server แกะจากไฟล์ .docx ที่อัปโหลดเอง เก็บ**ทั้งก้อนเหมือน " +
|
||||
"return ของ `/api/template/extract`** (`{cc, bookmark, properties, verify}` — ผ่าน helper " +
|
||||
"ตัวเดียวกัน) โดย `verify.HmacSha256` ตรวจด้วย secret ของ ticket ฝั่ง server — " +
|
||||
"**ห้ามส่ง field `fields` ใน form (ส่งมา = 400)**",
|
||||
security: [{ DocverBearer: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
|
@ -730,13 +734,6 @@ export const openApiSpec = {
|
|||
type: "string",
|
||||
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
||||
},
|
||||
fields: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' +
|
||||
'`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' +
|
||||
'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}',
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
||||
|
|
@ -1166,6 +1163,40 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
ExtractSnapshot: {
|
||||
type: "object",
|
||||
description:
|
||||
"ก้อนข้อมูลแกะจากไฟล์ .docx — รูปเดียวกับ return ของ POST /api/template/extract " +
|
||||
"(ใช้ helper ตัวเดียวกันใน lib/lib-tmpl.ts)",
|
||||
properties: {
|
||||
cc: {
|
||||
type: "object",
|
||||
description: "ค่า Content Control ทุกตัว รูปแบบ `{ [tag]: ข้อความ }`",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
bookmark: {
|
||||
type: "object",
|
||||
description: "ข้อความใน bookmark รูปแบบ `{ [ชื่อ bookmark]: ข้อความ }`",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
properties: {
|
||||
type: "object",
|
||||
description: "document properties — standard (core) / extended (app) / custom",
|
||||
properties: {
|
||||
standard: { type: "object", additionalProperties: { type: "string" } },
|
||||
extended: { type: "object", additionalProperties: { type: "string" } },
|
||||
custom: { type: "object", additionalProperties: {} },
|
||||
},
|
||||
},
|
||||
verify: {
|
||||
type: "object",
|
||||
description:
|
||||
"ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (วันที่ไทย +07:00) และ HmacSha256 " +
|
||||
"(boolean — ตรวจด้วย secret ของ ticket ฝั่ง server)",
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
VersionRecord: {
|
||||
type: "object",
|
||||
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
|
||||
|
|
@ -1180,11 +1211,11 @@ export const openApiSpec = {
|
|||
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
|
||||
},
|
||||
fields: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description:
|
||||
"ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)",
|
||||
additionalProperties: { type: "string" },
|
||||
"snapshot ตอนอัปโหลด — **ก้อนเดียวกับ return ของ /api/template/extract** (server " +
|
||||
"แกะจากไฟล์ .docx เอง; null = version เก่าก่อน 2026-10-30)",
|
||||
allOf: [{ $ref: "#/components/schemas/ExtractSnapshot" }],
|
||||
},
|
||||
message: { type: "string", nullable: true, description: "commit message" },
|
||||
hasPdf: { type: "boolean" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue