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>
467 lines
22 KiB
TypeScript
467 lines
22 KiB
TypeScript
import {
|
||
boolean,
|
||
doublePrecision,
|
||
index,
|
||
integer,
|
||
jsonb,
|
||
pgEnum,
|
||
pgTable,
|
||
text,
|
||
timestamp,
|
||
uniqueIndex,
|
||
uuid,
|
||
} from "drizzle-orm/pg-core";
|
||
|
||
// ─── Enum ────────────────────────────────────────────────────────────────────
|
||
|
||
export const annotationType = pgEnum("annotation_type", [
|
||
"HIGHLIGHT",
|
||
"DRAW",
|
||
"COMMENT",
|
||
"SHAPE",
|
||
"REDACTION",
|
||
"TEXTBOX",
|
||
"STAMP",
|
||
]);
|
||
|
||
export type AnnotationType = (typeof annotationType.enumValues)[number];
|
||
|
||
// ─── Strict JSONB union (PLAN.md §3, PLAN-FEAT.md §1) ───────────────────────
|
||
|
||
/** ทุกค่าเป็น % ของขนาดหน้า PDF */
|
||
export type Rect = { x: number; y: number; width: number; height: number };
|
||
|
||
/** พิกัดจุดเดี่ยว (% ของขนาดหน้า) — define ที่นี่แล้ว re-export จาก utils/coordinates.ts (PLAN-FEAT.md §1) */
|
||
export type Point = { x: number; y: number };
|
||
|
||
/** รูปแบบการมาร์กข้อความที่คลุมดำ — ไฮไลต์ทึบ / เส้นใต้ / ขีดฆ่า / เส้นขยุกขยิก */
|
||
export type HighlightStyle = "highlight" | "underline" | "strikethrough" | "squiggle";
|
||
|
||
export type HighlightData = {
|
||
text: string;
|
||
color: string;
|
||
rects: Rect[];
|
||
/** optional — ไม่มี = highlight ทึบตามเดิม (แถวเดิมก่อนมี 4 โหมด) */
|
||
style?: HighlightStyle;
|
||
/** optional — ความทึบ 0–1; ไม่มี = ทึบเต็ม (แถวเดิมก่อนมีตัวปรับความทึบ) */
|
||
opacity?: number;
|
||
/** โน้ตคำอธิบายของรายการนี้ เช่น เหตุผลที่วาด/ไฮไลต์ (optional — ทุกชนิดยกเว้น COMMENT ที่มี message ทำหน้าที่นี้อยู่แล้ว) */
|
||
note?: string;
|
||
};
|
||
|
||
/** ชนิดปากกาของเส้นวาด (tool DRAW) — highlighter = ปากไฮไลต์โปร่งแสง เส้นหนากว่า */
|
||
export type DrawPen = "pencil" | "highlighter";
|
||
|
||
/** paths เป็น % และ strokeWidth เป็น % ของความกว้างหน้า */
|
||
export type DrawData = {
|
||
strokeColor: string;
|
||
strokeWidth: number;
|
||
paths: Point[];
|
||
/** F7/E1 (optional): ความหนาต่อจุด (% ของความกว้างหน้า) จาก pressure ของสไตลัส — มีเฉพาะเส้นที่วาดด้วยปากกา, ยาวเท่า paths */
|
||
widths?: number[];
|
||
/** optional — ไม่ระบุ = pencil (แถวเดิมก่อนมี highlighter); สี/ความหนา/ความทึบ preset ต่อ pen อยู่ที่ DRAW_PENS ใน utils/annotation-display.ts */
|
||
pen?: DrawPen;
|
||
/** optional — ความทึบ 0–1; เส้นใหม่ (หลังมีตัวปรับความทึบ) เก็บชัดเสมอ ส่วนแถวเดิมที่ไม่มี = fallback ตาม preset ของ pen (ดู effectiveDrawOpacity ใน utils/annotation-display.ts) */
|
||
opacity?: number;
|
||
/** โน้ตคำอธิบายของรายการนี้ เช่น เหตุผลที่วาดเส้น (optional) */
|
||
note?: string;
|
||
};
|
||
|
||
export type CommentData = { position: Point; message: string };
|
||
|
||
/** ชนิดรูปทรง (F5/B1) — line/arrow เก็บจุด 2 จุดแทน rect เพื่อรักษาทิศทาง/ความเฉียง (D3) */
|
||
export type RectShapeKind = "rect" | "ellipse";
|
||
export type LineShapeKind = "line" | "arrow";
|
||
export type ShapeKind = RectShapeKind | LineShapeKind;
|
||
|
||
export type ShapeData =
|
||
| { shape: RectShapeKind; rect: Rect; strokeColor: string; strokeWidth: number; note?: string }
|
||
| { shape: LineShapeKind; from: Point; to: Point; strokeColor: string; strokeWidth: number; note?: string };
|
||
|
||
/** ปิดพราง (F8/B4) — สี่เหลี่ยมดำทึบ ไม่มีสีให้เปลี่ยน; พิกัด rect เป็น % ของหน้า */
|
||
export type RedactionData = { rect: Rect; note?: string };
|
||
|
||
/** ข้อความแบ่งช่วงสไตล์ (rich text ในกล่องเดียว) — เช่นเลือกบางส่วนทำตัวหนา */
|
||
export type TextboxSegment = { text: string; bold?: boolean };
|
||
|
||
/**
|
||
* กล่องข้อความบนหน้า (F17/B2) — position เป็นมุมซ้ายบนของกล่อง
|
||
* fontSize เป็น % ของความกว้างหน้าเช่นเดียวกับ strokeWidth
|
||
*/
|
||
export type TextboxData = {
|
||
position: Point;
|
||
text: string;
|
||
color: string;
|
||
fontSize: number;
|
||
/** ตัวหนา (optional) — export ต้องมี Sarabun-Bold.ttf ใน public/fonts ไม่งั้น fallback เป็น Regular */
|
||
bold?: boolean;
|
||
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
|
||
opacity?: number;
|
||
/** จัดแนวข้อความในกล่อง (optional) — ไม่มี = left · กล่องไม่ขยับ ขยับแค่ข้อความข้างใน (แบบ Smallpdf) */
|
||
align?: "left" | "center" | "right";
|
||
/** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา;
|
||
* field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */
|
||
segments?: TextboxSegment[];
|
||
/** ความกว้างกล่องคงที่ % ของความกว้างหน้า (optional — สไตล์ Smallpdf/Acrobat: ลากขอบ
|
||
* ปรับความกว้าง ข้อความ wrap ตาม) — ไม่มี = กล่อง shrink-to-fit หุ้มข้อความ (แถวเดิม) */
|
||
width?: number;
|
||
/** โน้ตคำอธิบายของรายการนี้ (optional) */
|
||
note?: string;
|
||
};
|
||
|
||
/** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง)
|
||
* color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */
|
||
/** rotation = มุมเอียงของตรายางเป็นองศา (−180..180, หมุนรอบ**กลางกรอบ** — ลบ =
|
||
* ทวนเข็มนาฬิกา) ไม่มี field = ไม่เอียง (0°) — แถวเดิมไม่มี ใช้ร่วมกันได้ */
|
||
export type StampData = {
|
||
label: string;
|
||
position: Point;
|
||
scale: number;
|
||
color?: string;
|
||
note?: string;
|
||
rotation?: number;
|
||
};
|
||
|
||
/** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */
|
||
export type AnnotationData =
|
||
| HighlightData
|
||
| DrawData
|
||
| CommentData
|
||
| ShapeData
|
||
| RedactionData
|
||
| TextboxData
|
||
| StampData;
|
||
|
||
// ─── Tables ──────────────────────────────────────────────────────────────────
|
||
|
||
export const files = pgTable(
|
||
"files",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
title: text("title").notNull(),
|
||
description: text("description"),
|
||
pdfUrl: text("pdf_url").notNull(),
|
||
/** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:<fileId>" / "url:<href>") — NULL = ไฟล์ seed/อัปโหลด */
|
||
externalId: text("external_id"),
|
||
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` หรือช่องกลุ่มของฟอร์มอัปโหลด N4 — NULL = ไม่มีกลุ่ม)
|
||
* ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */
|
||
typeId: text("type_id"),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
(t) => [
|
||
index("files_type_id_idx").on(t.typeId),
|
||
// unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (external_id = NULL) ไม่ชนกัน
|
||
uniqueIndex("files_external_id_key").on(t.externalId),
|
||
],
|
||
);
|
||
|
||
export const annotations = pgTable(
|
||
"annotations",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
fileId: uuid("file_id")
|
||
.notNull()
|
||
.references(() => files.id, { onDelete: "cascade" }),
|
||
pageIndex: integer("page_index").notNull(),
|
||
userId: text("user_id").notNull(),
|
||
userName: text("user_name").notNull(),
|
||
type: annotationType("type").notNull(),
|
||
data: jsonb("data").$type<AnnotationData>().notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
/** F10/A5: จัดการแล้ว — ใครก็ได้ toggle (D6) ไม่ทำลายข้อมูล */
|
||
resolved: boolean("resolved").notNull().default(false),
|
||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||
},
|
||
(t) => [index("annotations_file_page_idx").on(t.fileId, t.pageIndex)],
|
||
);
|
||
|
||
export const bookmarks = pgTable(
|
||
"bookmarks",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
fileId: uuid("file_id")
|
||
.notNull()
|
||
.references(() => files.id, { onDelete: "cascade" }),
|
||
pageIndex: integer("page_index").notNull(),
|
||
userId: text("user_id").notNull(),
|
||
/** N6: ชื่อผู้บันทึก (mirror annotations — เดิมมีแค่ user_id จนกรองตามผู้บันทึกแล้วไม่มีชื่อแสดง);
|
||
* '' = row เดิมก่อน backfill → UI แสดง "ไม่ทราบชื่อ" */
|
||
userName: text("user_name").notNull().default(""),
|
||
title: text("title").notNull(),
|
||
/** N2: bookmark แม่ (NULL = หมุดหัวข้อใหญ่ระดับบนสุด) — ไม่ทำ FK เพราะลบแม่ต้อง re-parent ลูกขึ้น root ก่อนใน tx เดียวกัน */
|
||
parentId: uuid("parent_id"),
|
||
/** N2: โน้ตย่อยของ bookmark (optional) */
|
||
note: text("note"),
|
||
/** % ของความสูงหน้า (0–100) */
|
||
yOffset: doublePrecision("y_offset").notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
);
|
||
|
||
/** คำตอบในเธรดของ comment (F9/A4 — D2: ตารางแยก ไม่ใช้ parentId ในตารางเดิม) */
|
||
export const annotationReplies = pgTable(
|
||
"annotation_replies",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
annotationId: uuid("annotation_id")
|
||
.notNull()
|
||
.references(() => annotations.id, { onDelete: "cascade" }),
|
||
userId: text("user_id").notNull(),
|
||
userName: text("user_name").notNull(),
|
||
message: text("message").notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
(t) => [index("annotation_replies_annotation_idx").on(t.annotationId)],
|
||
);
|
||
|
||
/**
|
||
* ประวัติการเปลี่ยนแปลงของ annotation (F19/A7)
|
||
* `annotation_id` เป็น uuid ธรรมดา **ไม่ FK** (D8) — snapshot ตอน delete ต้องรอดจาก
|
||
* CASCADE ของแม่; `page_index` เก็บหน้าของ annotation ตอน snapshot เพื่อให้ restore
|
||
* สร้างใหม่บนหน้าเดิมได้
|
||
*/
|
||
export const annotationVersions = pgTable(
|
||
"annotation_versions",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
annotationId: uuid("annotation_id").notNull(),
|
||
fileId: uuid("file_id")
|
||
.notNull()
|
||
.references(() => files.id, { onDelete: "cascade" }),
|
||
pageIndex: integer("page_index").notNull(),
|
||
userId: text("user_id").notNull(),
|
||
userName: text("user_name").notNull(),
|
||
type: annotationType("type").notNull(),
|
||
data: jsonb("data").$type<AnnotationData>().notNull(),
|
||
/** 'update' = snapshot ก่อนเขียนทับ · 'delete' = snapshot ก่อนลบ */
|
||
kind: text("kind").notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
||
);
|
||
|
||
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
|
||
|
||
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
|
||
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
|
||
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
|
||
export const documents = pgTable(
|
||
"documents",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
|
||
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
|
||
docId: text("doc_id").notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
|
||
);
|
||
|
||
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
|
||
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
|
||
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
|
||
export const documentVersions = pgTable(
|
||
"document_versions",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
documentId: uuid("document_id")
|
||
.notNull()
|
||
.references(() => documents.id, { onDelete: "cascade" }),
|
||
versionNumber: integer("version_number").notNull(),
|
||
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
|
||
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
|
||
clientVersionId: text("client_version_id"),
|
||
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
|
||
contentHash: text("content_hash").notNull(),
|
||
/** commit message (FR-13) — trim แล้วว่าง = NULL */
|
||
message: text("message"),
|
||
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
|
||
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
|
||
fields: jsonb("fields").$type<Record<string, string>>(),
|
||
templateId: text("template_id"),
|
||
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
|
||
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
|
||
fileName: text("file_name"),
|
||
docxObjectKey: text("docx_object_key").notNull(),
|
||
pdfObjectKey: text("pdf_object_key"),
|
||
docxSize: integer("docx_size").notNull(),
|
||
pdfSize: integer("pdf_size"),
|
||
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
|
||
userId: text("user_id").notNull(),
|
||
userName: text("user_name").notNull(),
|
||
createdAt: timestamp("created_at", { withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
},
|
||
(t) => [
|
||
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
|
||
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
|
||
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
|
||
],
|
||
);
|
||
|
||
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||
|
||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||
export type DocumentRow = typeof documents.$inferSelect;
|
||
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||
|
||
export type AnnotationDTO = {
|
||
id: string;
|
||
fileId: string;
|
||
pageIndex: number;
|
||
userId: string;
|
||
userName: string;
|
||
type: AnnotationType;
|
||
data: AnnotationData;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
/** F10/A5 */
|
||
resolved: boolean;
|
||
resolvedAt: string | null;
|
||
};
|
||
|
||
export type BookmarkDTO = {
|
||
id: string;
|
||
fileId: string;
|
||
pageIndex: number;
|
||
userId: string;
|
||
/** N6 — '' = ไม่ทราบชื่อ (row เดิมก่อน backfill) */
|
||
userName: string;
|
||
title: string;
|
||
/** N2: NULL = ระดับบนสุด / มีค่า = sub ของ bookmark นั้น */
|
||
parentId: string | null;
|
||
/** N2: โน้ตย่อย (null = ไม่มี) */
|
||
note: string | null;
|
||
yOffset: number;
|
||
createdAt: string;
|
||
};
|
||
|
||
export type ReplyDTO = {
|
||
id: string;
|
||
annotationId: string;
|
||
userId: string;
|
||
userName: string;
|
||
message: string;
|
||
createdAt: string;
|
||
};
|
||
|
||
export type VersionKind = "update" | "delete";
|
||
|
||
export type AnnotationVersionDTO = {
|
||
id: string;
|
||
annotationId: string;
|
||
fileId: string;
|
||
pageIndex: number;
|
||
userId: string;
|
||
userName: string;
|
||
type: AnnotationType;
|
||
data: AnnotationData;
|
||
kind: VersionKind;
|
||
createdAt: string;
|
||
};
|
||
|
||
export function toAnnotationDTO(row: AnnotationRow): AnnotationDTO {
|
||
return {
|
||
id: row.id,
|
||
fileId: row.fileId,
|
||
pageIndex: row.pageIndex,
|
||
userId: row.userId,
|
||
userName: row.userName,
|
||
type: row.type,
|
||
data: row.data,
|
||
createdAt: row.createdAt.toISOString(),
|
||
updatedAt: row.updatedAt.toISOString(),
|
||
resolved: row.resolved,
|
||
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
|
||
};
|
||
}
|
||
|
||
export function toBookmarkDTO(row: BookmarkRow): BookmarkDTO {
|
||
return {
|
||
id: row.id,
|
||
fileId: row.fileId,
|
||
pageIndex: row.pageIndex,
|
||
userId: row.userId,
|
||
userName: row.userName,
|
||
title: row.title,
|
||
parentId: row.parentId,
|
||
note: row.note,
|
||
yOffset: row.yOffset,
|
||
createdAt: row.createdAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
export function toReplyDTO(row: ReplyRow): ReplyDTO {
|
||
return {
|
||
id: row.id,
|
||
annotationId: row.annotationId,
|
||
userId: row.userId,
|
||
userName: row.userName,
|
||
message: row.message,
|
||
createdAt: row.createdAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
||
return {
|
||
id: row.id,
|
||
annotationId: row.annotationId,
|
||
fileId: row.fileId,
|
||
pageIndex: row.pageIndex,
|
||
userId: row.userId,
|
||
userName: row.userName,
|
||
type: row.type,
|
||
data: row.data,
|
||
kind: row.kind === "delete" ? "delete" : "update",
|
||
createdAt: row.createdAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||
export type DocumentVersionDTO = {
|
||
versionId: string;
|
||
versionNumber: number;
|
||
createdAt: string;
|
||
createdByUserId: string;
|
||
createdByUserName: string;
|
||
contentHash: string;
|
||
fields: Record<string, string> | null;
|
||
message: string | null;
|
||
hasPdf: boolean;
|
||
};
|
||
|
||
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||
return {
|
||
versionId: row.id,
|
||
versionNumber: row.versionNumber,
|
||
createdAt: row.createdAt.toISOString(),
|
||
createdByUserId: row.userId,
|
||
createdByUserName: row.userName,
|
||
contentHash: row.contentHash,
|
||
fields: row.fields,
|
||
message: row.message,
|
||
hasPdf: row.pdfObjectKey !== null,
|
||
};
|
||
}
|