eCourt-note/db/schema.ts
Your Name c5c6b65bd2 genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00

364 lines
16 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";
// ─── 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(),
/** 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)],
);
// ─── 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;
/** 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(),
};
}