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:" / "url:") — 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().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().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//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>(), 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 | 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, }; }