eCourt-note/app/actions/annotations.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00

951 lines
41 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.

"use server";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
import { db } from "@/db";
import {
annotationReplies,
annotationVersions,
annotations,
bookmarks,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
toVersionDTO,
type AnnotationData,
type AnnotationDTO,
type AnnotationType,
type AnnotationVersionDTO,
type BookmarkDTO,
type CommentData,
type DrawData,
type DrawPen,
type HighlightData,
type Rect,
type RedactionData,
type ReplyDTO,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = {
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
};
export type CreateBookmarkInput = {
fileId: string;
pageIndex: number;
userId: string;
title: string;
/** % ของความสูงหน้า (0–100) */
yOffset: number;
/** N2: id ของหมุดหัวข้อแม่ (null = ระดับบนสุด) */
parentId?: string | null;
/** N2: โน้ตย่อย (null/ว่าง = ไม่มี) */
note?: string | null;
};
export type UpdateBookmarkInput = {
id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string;
note?: string | null;
/** ย้ายหัวข้อ: null = ระดับบนสุด / id = ใต้หัวข้อนั้น (ต้องเป็น root) */
parentId?: string | null;
};
// ─── Validation guards — ตรวจทุก field ก่อนแตะ DB (rules/data-layer.md) ──────
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ANNOTATION_TYPES: readonly AnnotationType[] = [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
];
const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้
const MAX_BOOKMARK_NOTE_CHARS = 200;
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
function fieldError(field: string, expect: string): never {
throw new Error(`input ไม่ถูกต้อง: ฟิลด์ ${field} ต้อง${expect}`);
}
function vString(v: unknown, field: string, maxChars?: number): string {
if (typeof v !== "string" || v.trim().length === 0) fieldError(field, "เป็น string ที่ไม่ว่าง");
if (maxChars !== undefined && v.length > maxChars) {
fieldError(field, `ยาวไม่เกิน ${maxChars} ตัวอักษร`);
}
return v;
}
/** โน้ตคำอธิบาย (note) เป็น optional — ค่าว่าง/ช่องว่างล้วนถือว่าไม่มีโน้ต */
function vNote(v: unknown): string | undefined {
if (v === undefined) return undefined;
if (typeof v !== "string") fieldError("data.note", "เป็น string");
if (v.length > MAX_TEXT_CHARS) {
fieldError("data.note", `ยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : undefined;
}
function vUuid(v: unknown, field: string): string {
if (typeof v !== "string" || !UUID_RE.test(v)) fieldError(field, "เป็น UUID");
return v;
}
function vPageIndex(v: unknown): number {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
fieldError("pageIndex", "เป็นจำนวนเต็ม ≥ 0");
}
return v;
}
/** ตัวเลขพิกัด % — finite และ ≥ 0 */
function vPercent(v: unknown, field: string): number {
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fieldError(field, "เป็นตัวเลข ≥ 0");
return v;
}
function vPoint(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
}
function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>;
return {
x: vPercent(r.x, `${field}.x`),
y: vPercent(r.y, `${field}.y`),
width: vPercent(r.width, `${field}.width`),
height: vPercent(r.height, `${field}.height`),
};
}
function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationData {
if (typeof v !== "object" || v === null) fieldError("data", `เป็น object ของประเภท ${type}`);
const d = v as Record<string, unknown>;
switch (type) {
case "HIGHLIGHT": {
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
if (!Array.isArray(d.rects) || d.rects.length === 0) {
fieldError("data.rects", "เป็น array ที่ไม่ว่าง");
}
const rects = d.rects.map((r, i) => vRect(r, `data.rects[${i}]`));
const note = vNote(d.note);
// รูปแบบการมาร์ก (style) เป็น optional — ไม่มี = highlight ทึบตามเดิม
if (
d.style !== undefined &&
d.style !== "highlight" &&
d.style !== "underline" &&
d.style !== "strikethrough" &&
d.style !== "squiggle"
) {
fieldError(
"data.style",
'เป็น "highlight" | "underline" | "strikethrough" | "squiggle"',
);
}
// ความทึบ (opacity 0–1) เป็น optional — ไม่มี = ทึบเต็ม (แถวเดิม)
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
return {
text,
color,
rects,
...(d.style !== undefined ? { style: d.style } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(note !== undefined ? { note } : {}),
} satisfies HighlightData;
}
case "DRAW": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (!Array.isArray(d.paths) || d.paths.length < 2) {
fieldError("data.paths", "เป็น array ที่มีจุดอย่างน้อย 2 จุด");
}
const paths = d.paths.map((p, i) => vPoint(p, `data.paths[${i}]`));
// ชนิดปากกา (pen) เป็น optional — ไม่มี = pencil (แถวเดิมก่อนมี highlighter)
let pen: DrawPen | undefined;
if (d.pen !== undefined) {
if (d.pen !== "pencil" && d.pen !== "highlighter") {
fieldError("data.pen", 'เป็น "pencil" หรือ "highlighter"');
}
pen = d.pen;
}
// ความทึบ (opacity 0–1) เป็น optional — แถวเดิมไม่มี = ตามค่า preset ของ pen
let opacity: number | undefined;
if (d.opacity !== undefined) {
if (
typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
opacity = d.opacity;
}
// F7/E1: widths (pressure) เป็น optional — ถ้ามีต้องยาวเท่า paths พอดี
let widths: number[] | undefined;
if (d.widths !== undefined) {
if (!Array.isArray(d.widths) || d.widths.length !== paths.length) {
fieldError("data.widths", "เป็น array ความหนาที่ยาวเท่า data.paths");
}
widths = d.widths.map((w, i) => vPercent(w, `data.widths[${i}]`));
}
const note = vNote(d.note);
return {
strokeColor,
strokeWidth,
paths,
...(pen ? { pen } : {}),
...(opacity !== undefined ? { opacity } : {}),
...(widths ? { widths } : {}),
...(note !== undefined ? { note } : {}),
} satisfies DrawData;
}
case "COMMENT": {
const position = vPoint(d.position, "data.position");
const message = vString(d.message, "data.message", MAX_TEXT_CHARS);
return { position, message } satisfies CommentData;
}
case "REDACTION": {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return { rect, ...(note !== undefined ? { note } : {}) } satisfies RedactionData;
}
case "TEXTBOX": {
const position = vPoint(d.position, "data.position");
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
// fontSize เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.fontSize !== "number" ||
!Number.isFinite(d.fontSize) ||
d.fontSize <= 0
) {
fieldError("data.fontSize", "เป็นตัวเลข > 0");
}
// bold/opacity/align เป็น optional — ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย (แถวเดิม)
if (d.bold !== undefined && typeof d.bold !== "boolean") {
fieldError("data.bold", "เป็น boolean");
}
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
if (
d.align !== undefined &&
d.align !== "left" &&
d.align !== "center" &&
d.align !== "right"
) {
fieldError("data.align", 'เป็น "left" | "center" | "right"');
}
// segments (ตัวหนาเฉพาะส่วน) เป็น optional array ของ { text, bold? }
if (d.segments !== undefined) {
if (!Array.isArray(d.segments)) {
fieldError("data.segments", "เป็น array ของ { text, bold? }");
}
for (const seg of d.segments) {
if (seg === null || typeof seg !== "object") {
fieldError("data.segments", "เป็น object { text, bold? }");
}
if (typeof (seg as { text?: unknown })?.text !== "string") {
fieldError("data.segments[].text", "เป็น string");
}
if (
(seg as { bold?: unknown })?.bold !== undefined &&
typeof (seg as { bold?: unknown }).bold !== "boolean"
) {
fieldError("data.segments[].bold", "เป็น boolean");
}
}
// FIX-4 (ต่อ): ความยาวรวมของทุกส่วนต้องเข้าขอบเดียวกับ data.text — ไม่งั้น
// เรียก action ตรง ๆ ด้วย segments ยักษ์ (แยกจาก text ที่ส่งมาสั้น) ก็ยังฝัง
// ข้อความขนาดหลาย MB ลง JSONB + SSE event ใหญ่ผิดปกติได้เหมือนเดิม
const segTotal = d.segments
.map((seg) => (seg as { text: string }).text)
.join("").length;
if (segTotal > MAX_TEXT_CHARS) {
fieldError("data.segments", `ข้อความรวมทุกส่วนยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
}
const textboxNote = vNote(d.note);
return {
position,
text,
color,
fontSize: d.fontSize,
...(d.bold ? { bold: true } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(d.align !== undefined ? { align: d.align } : {}),
...(d.segments !== undefined ? { segments: d.segments } : {}),
...(textboxNote !== undefined ? { note: textboxNote } : {}),
} satisfies TextboxData;
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
const position = vPoint(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
!Number.isFinite(d.scale) ||
d.scale <= 0
) {
fieldError("data.scale", "เป็นตัวเลข > 0");
}
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note);
return {
label,
position,
scale: d.scale,
...(color ? { color } : {}),
...(note !== undefined ? { note } : {}),
} satisfies StampData;
}
case "SHAPE": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (typeof d.shape !== "string" || !SHAPE_KINDS.includes(d.shape)) {
return fieldError("data.shape", `เป็นหนึ่งใน ${SHAPE_KINDS.join(" | ")}`);
}
if (d.shape === "rect" || d.shape === "ellipse") {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return {
shape: d.shape as "rect" | "ellipse",
rect,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
const from = vPoint(d.from, "data.from");
const to = vPoint(d.to, "data.to");
const note = vNote(d.note);
return {
shape: d.shape as "line" | "arrow",
from,
to,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
}
}
// ─── Actions ─────────────────────────────────────────────────────────────────
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
}
const type = raw.type as AnnotationType;
const data = validateAnnotationData(type, raw?.data);
const [row] = await db
.insert(annotations)
.values({ fileId, pageIndex, userId, userName, type, data })
.returning();
const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
return dto;
}
/**
* ลบได้เฉพาะรายการของตัวเอง (userId ตรงกัน) เท่านั้น
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง
*/
export async function deleteAnnotation(id: string, userId: string): Promise<void> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const removed = await db.transaction(async (tx) => {
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
const [existing] = await tx
.select()
.from(annotations)
.where(and(eq(annotations.id, annId), eq(annotations.userId, uid)))
.for("update");
if (!existing) return null;
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "delete",
});
const [del] = await tx
.delete(annotations)
.where(eq(annotations.id, annId))
.returning({ id: annotations.id, fileId: annotations.fileId });
return del ?? null;
});
if (!removed) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ annotation นี้ หรือไม่ใช่รายการของคุณ");
}
publishAnnotateEvent(removed.fileId, {
table: "annotations",
action: "delete",
id: annId,
});
}
/**
* แก้ไข data ของ annotation ที่มีอยู่ (PLAN-FEAT.md §3 F1)
* - เจ้าของเท่านั้น (D9) และห้ามเปลี่ยน type/pageIndex — validate ตาม type เดิมของ row
* - ย้าย/ปรับขนาดได้ในหน้าเดียวกันเท่านั้น (pageIndex ไม่ถูกแตะ)
* - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
*/
export async function updateAnnotation(
id: string,
userId: string,
data: AnnotationData,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, annId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// validate ด้วย validator เดิมตาม type ของ row — data ผิด variant ของ type นั้นจะถูกปฏิเสธ
const validated = validateAnnotationData(existing.type, data);
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(row.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
/**
* F10/A5: toggle สถานะ "จัดการแล้ว" — **ใครก็ได้** (D6: คลายคืนได้ ไม่ทำลายข้อมูล
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
*/
export async function setAnnotationResolved(
id: string,
resolved: boolean,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
if (typeof resolved !== "boolean") {
fieldError("resolved", "เป็น boolean");
}
const [existing] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annId));
if (!existing) {
throw new Error("ไม่พบ annotation นี้");
}
const [row] = await db
.update(annotations)
.set({ resolved, resolvedAt: resolved ? new Date() : null, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
if (!row) {
throw new Error("ตั้งสถานะไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(existing.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
// ─── Replies (F9/A4 — D2: ตารางแยก annotation_replies, FK CASCADE) ──────────
export type CreateReplyInput = {
annotationId: string;
userId: string;
userName: string;
message: string;
};
export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const annotationId = vUuid(raw?.annotationId, "annotationId");
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
const message = vString(raw?.message, "message", MAX_TEXT_CHARS);
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annotationId));
if (!parent) {
throw new Error("ตอบไม่ได้: ไม่พบ comment ต้นทาง");
}
const [row] = await db
.insert(annotationReplies)
.values({ annotationId, userId, userName, message })
.returning();
const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
return dto;
}
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */
export async function deleteReply(id: string, userId: string): Promise<void> {
const replyId = vUuid(id, "id");
const uid = vString(userId, "userId");
const [existing] = await db
.select({ annotationId: annotationReplies.annotationId })
.from(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)));
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
// หา fileId ของแม่ไว้ publish (แม่หายไปแล้ว = CASCADE ลบ reply ทิ้ง แทบไม่เกิด)
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, existing.annotationId));
const removed = await db
.delete(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)))
.returning({ id: annotationReplies.id });
if (removed.length === 0) {
// race: ถูกลบไปแล้วระหว่าง select → delete — ไม่ publish event ทับ
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
if (parent) {
publishAnnotateEvent(parent.fileId, { table: "replies", action: "delete", id: replyId });
}
}
/** โน้ตย่อยของ bookmark — ว่าง/ช่องว่างล้วน = null (ไม่มีโน้ต) */
function vBookmarkNote(v: unknown): string | null {
if (v === undefined || v === null) return null;
if (typeof v !== "string") fieldError("note", "เป็น string");
if (v.length > MAX_BOOKMARK_NOTE_CHARS) {
fieldError("note", `ยาวไม่เกิน ${MAX_BOOKMARK_NOTE_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : null;
}
// ─── N2: โครงสร้างต้นไม้ bookmark (3 ระดับ) — helper ทุกตัว lock แถวที่เดินผ่าน
// เพื่อ serialize กับ create/update/delete ที่แตะโหนดเดียวกัน (กัน race ความลึก/orphan)
/** ความลึกสูงสุดของต้นไม้ bookmark (root = ระดับ 1) */
const BOOKMARK_MAX_DEPTH = 3;
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
/** cap การเดินต้นไม้ — ปกติลึกสุด 3 ระดับ ถ้าเกินนี้แปลว่าข้อมูลวนซ้ำ (cycle) ให้ fail ดีกว่าลูปไม่รู้จบ */
const TREE_WALK_LIMIT = 100;
/** ความลึกของ bookmark (root = 1) + ไฟล์ของตัวมัน — เดินขึ้นหาบรรพบุรุษพร้อม lock ทุกตัว */
async function lockedDepth(tx: Tx, id: string): Promise<{ depth: number; fileId: string }> {
let depth = 1;
let cur = id;
for (;;) {
if (depth > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const [row] = await tx
.select({ fileId: bookmarks.fileId, parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row) {
throw new Error("ไม่พบหมุดหัวข้อในลำดับชั้น (อาจถูกลบไปแล้ว)");
}
if (row.parentId === null) return { depth, fileId: row.fileId };
depth += 1;
cur = row.parentId;
}
}
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) — เดินลงหาลูกทุกตัวพร้อม lock (กัน create แข่งเพิ่มความลึกระหว่างย้าย) */
async function lockedSubtreeHeight(tx: Tx, id: string, level = 1): Promise<number> {
if (level > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const kids = await tx
.select({ id: bookmarks.id })
.from(bookmarks)
.where(eq(bookmarks.parentId, id))
.for("update");
let height = 1;
for (const k of kids) {
height = Math.max(height, 1 + (await lockedSubtreeHeight(tx, k.id, level + 1)));
}
return height;
}
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) — ใช้กันการย้ายเข้า subtree ของตัวเอง */
async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boolean> {
let cur = id;
for (let steps = 0; steps <= TREE_WALK_LIMIT; steps += 1) {
const [row] = await tx
.select({ parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row || row.parentId === null) return false;
if (row.parentId === ancestorId) return true;
cur = row.parentId;
}
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
export async function createBookmark(input: CreateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if (
typeof raw?.yOffset !== "number" ||
!Number.isFinite(raw.yOffset) ||
raw.yOffset < 0 ||
raw.yOffset > 100
) {
fieldError("yOffset", "เป็นตัวเลข 0–100");
}
const note = vBookmarkNote(raw?.note);
// capture เป็นค่าคงที่ก่อนเข้า closure ของ transaction (narrowing ไม่ไหลเข้า callback)
const yOffset = raw.yOffset as number;
const row = await db.transaction(async (tx) => {
// N2: ถ้าระบุแม่ — ต้องอยู่ไฟล์เดียวกันและลึกไม่เกิน MAX_DEPTH-1 ระดับ
// (สร้างเป็นลูกแล้วรวมกันต้อง ≤ 3) — ตรวจพร้อม lock ทั้งสายบรรพบุรุษของแม่
// ภายใน tx เดียวกับ insert เพื่อ serialize กับ updateBookmark (ย้าย) และ
// deleteBookmark (ลบแม่ + re-parent) กัน race เกินความลึกหรือ sub กำพร้า
let parentId: string | null = null;
if (raw?.parentId !== undefined && raw?.parentId !== null && raw?.parentId !== "") {
const pid = vUuid(raw.parentId, "parentId");
const info = await lockedDepth(tx, pid);
if (info.fileId !== fileId) {
throw new Error("สร้างไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (info.depth >= BOOKMARK_MAX_DEPTH) {
throw new Error(`สร้างไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
const [inserted] = await tx
.insert(bookmarks)
.values({ fileId, pageIndex, userId, title, yOffset, parentId, note })
.returning();
return inserted;
});
const dto = toBookmarkDTO(row);
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
return dto;
}
/**
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง
*/
export async function updateBookmark(input: UpdateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const bmId = vUuid(raw?.id, "id");
const uid = vString(raw?.userId, "userId");
const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// ย้ายหัวข้อ (parentId) — validate 3 ข้อ: แม่อยู่ไฟล์เดียวกัน, ความลึกแม่ + ความสูง
// subtree ของตัวเองรวมกัน ≤ MAX_DEPTH, และห้ามย้ายเข้า subtree ของตัวเอง (cycle)
let parentId: string | null | undefined;
if (raw?.parentId !== undefined) {
if (raw.parentId === null || raw.parentId === "") {
parentId = null;
} else {
const pid = vUuid(raw.parentId, "parentId");
if (pid === bmId) {
throw new Error("ย้ายไม่ได้: ห้ามกำหนดตัวเองเป็นแม่");
}
const info = await lockedDepth(tx, pid);
if (info.fileId !== existing.fileId) {
throw new Error("ย้ายไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (await isDescendant(tx, pid, bmId)) {
throw new Error("ย้ายไม่ได้: ห้ามย้ายเข้าไปใน subtree ของตัวเอง");
}
const height = await lockedSubtreeHeight(tx, bmId);
if (info.depth + height > BOOKMARK_MAX_DEPTH) {
throw new Error(`ย้ายไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
}
const [updated] = await tx
.update(bookmarks)
.set({
...(title !== undefined ? { title } : {}),
...(note !== undefined ? { note } : {}),
...(parentId !== undefined ? { parentId } : {}),
})
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toBookmarkDTO(row);
publishAnnotateEvent(row.fileId, { table: "bookmarks", action: "update", row: dto });
return dto;
}
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */
export async function deleteBookmark(id: string, userId: string): Promise<void> {
const bmId = vUuid(id, "id");
const uid = vString(userId, "userId");
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId })
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("ลบไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// re-parent ลูกขึ้นระดับบนสุดก่อนลบ (parentId ไม่มี FK — ต้องจัดการเอง)
const children = await tx
.update(bookmarks)
.set({ parentId: null })
.where(eq(bookmarks.parentId, bmId))
.returning();
const removed = await tx
.delete(bookmarks)
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning({ id: bookmarks.id });
return { fileId: existing.fileId, children, removed };
});
if (result.removed.length === 0) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → delete
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "delete",
id: bmId,
});
// ลูกที่ถูกยกขึ้น root → update event ต่อตัว ให้ client อื่นย้ายตำแหน่งตาม
for (const child of result.children) {
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "update",
row: toBookmarkDTO(child),
});
}
}
// ─── Version History (F19/A7) ────────────────────────────────────────────────
/** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
export async function listVersions(
annotationId: string,
): Promise<AnnotationVersionDTO[]> {
const annId = vUuid(annotationId, "annotationId");
const rows = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.annotationId, annId))
.orderBy(desc(annotationVersions.createdAt));
return rows.map(toVersionDTO);
}
/**
* กู้คืนจาก snapshot:
* - annotation ต้นทาง**ยังอยู่** → เขียนข้อมูลของ version กลับลง row เดิม
* (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ — กู้คืนกลับไป-มาได้)
* แล้ว publish update event — เดิมสร้างสำเนา id ใหม่ทุกครั้งจนเกิดรายการซ้ำบนหน้า
* - ต้นทางถูกลบไปแล้ว → สร้าง annotation ใหม่ **id ใหม่** บนหน้าเดิม เจ้าของเดิม
* (D8: version รอดจาก CASCADE ของแม่) + publish create event ให้ทุก client
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
*/
export async function restoreVersion(
versionId: string,
userId: string,
): Promise<AnnotationDTO> {
const vId = vUuid(versionId, "versionId");
const uid = vString(userId, "userId");
const [v] = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.id, vId));
if (!v) {
throw new Error("ไม่พบ version นี้");
}
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, v.annotationId))
.for("update");
if (existing) {
if (existing.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// snapshot สถานะปัจจุบันก่อนเขียนกลับ — ผู้ใช้ย้อนกลับไป-กลับมาได้เหมือน undo
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
// validate ตาม type ของ row จริง — version อาจมาจาก type เดียวกันเสมอ (D8) แต่กันไว้
const validated = validateAnnotationData(existing.type, v.data);
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, existing.id))
.returning();
return { action: "update" as const, row: updated };
}
// ต้นทางถูกลบไปแล้ว — เจ้าของ = userId ของ snapshot (D9)
if (v.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
const [row] = await tx
.insert(annotations)
.values({
fileId: v.fileId,
pageIndex: v.pageIndex,
userId: v.userId,
userName: v.userName,
type: v.type,
data: v.data,
})
.returning();
return { action: "create" as const, row };
});
const dto = toAnnotationDTO(result.row);
publishAnnotateEvent(v.fileId, { table: "annotations", action: result.action, row: dto });
return dto;
}
// ─── Search (F14/D1) ─────────────────────────────────────────────────────────
/**
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern
*/
export async function searchAnnotations(fileId: string, q: string): Promise<AnnotationDTO[]> {
const fid = vUuid(fileId, "fileId");
const query = vString(q, "q", MAX_QUERY_CHARS);
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
const pattern = `%${esc}%`;
const rows = await db
.select()
.from(annotations)
.where(
and(
eq(annotations.fileId, fid),
or(
sql`${annotations.data}->>'text' ILIKE ${pattern}`,
sql`${annotations.data}->>'message' ILIKE ${pattern}`,
sql`${annotations.data}->>'note' ILIKE ${pattern}`,
),
),
)
.orderBy(asc(annotations.createdAt));
return rows.map(toAnnotationDTO);
}