eCourt-note/db/schema.ts

344 lines
14 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,
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 (แถวเดิมก่อนมีตัวเลือกสี) */
export type StampData = { label: string; position: Point; scale: number; color?: string; note?: string };
/** เลือกตาม 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(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
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(),
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)],
);
// ─── 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 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;
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,
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(),
};
}