eCourt-note/db/schema.ts
Suphonchai Phoonsawat cdae1ceedd 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.
2026-10-31 10:21:17 +07:00

478 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
boolean,
doublePrecision,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
// type-only — ไม่ลาก runtime ของ lib-tmpl มาที่ schema (ใช้เป็นชนิดของ fields jsonb)
import type { DocumentExtractSnapshot } from "@/lib/lib-tmpl";
// ─── 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 ผู้ใช้ */
/** fields ของ version — **ก้อนเดียวกับ return ของ `/api/template/extract`** (ผ่าน
* `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกัน): `{cc, bookmark, properties,
* verify}` แกะจากไฟล์ .docx ฝั่ง server ตอน upload (2026-10-30) · row เก่าก่อนวันนั้น
* เก็บ map ดิบ `{ [tag]: text }` / `{ [fieldKey]: {value, missing} }` — ฝั่งอ่านของ add-in
* (FieldsSnapshot + readSnapshotField) แยกรูปเอง จึงไม่ต้อง migrate ข้อมูลเก่า */
export type DocumentVersionFields = DocumentExtractSnapshot;
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: ก้อน extract snapshot `{cc, bookmark, properties, verify}`
* เหมือน return ของ /api/template/extract (ดู DocumentVersionFields เหนือตาราง) */
fields: jsonb("fields").$type<DocumentVersionFields>(),
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: DocumentVersionFields | 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,
};
}