eCourt-note/db/schema.ts

345 lines
14 KiB
TypeScript
Raw Normal View History

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(),
};
}