"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; 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; 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; 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 { const raw = input as unknown as Record | 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 { 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 { 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 { 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 { const raw = input as unknown as Record | 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 { 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[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 { 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 { 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 { const raw = input as unknown as Record | 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 { const raw = input as unknown as Record | 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 { 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 { 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 { 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 { 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); }