952 lines
41 KiB
TypeScript
952 lines
41 KiB
TypeScript
|
|
"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);
|
|||
|
|
}
|