1157 lines
53 KiB
TypeScript
1157 lines
53 KiB
TypeScript
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { and, asc, desc, eq, gte, lte, 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";
|
||
import {
|
||
MAX_BOOKMARK_NOTE_CHARS,
|
||
MAX_BOOKMARK_TITLE_CHARS,
|
||
} from "@/utils/bookmark-limits";
|
||
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
|
||
|
||
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
|
||
|
||
export type CreateAnnotationInput = {
|
||
fileId: string;
|
||
pageIndex: number;
|
||
userId: string;
|
||
userName: string;
|
||
type: AnnotationType;
|
||
data: AnnotationData;
|
||
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
|
||
* จะได้ไม่เห็นรายการ duplicate ชั่วขณะ (row ชั่วคราว + row จริงคนละ id) — ไม่ส่ง = สร้างใหม่ */
|
||
id?: string;
|
||
};
|
||
|
||
export type CreateBookmarkInput = {
|
||
fileId: string;
|
||
pageIndex: number;
|
||
userId: string;
|
||
/** N6: ชื่อผู้บันทึก — แสดงในผลค้นหา bookmark */
|
||
userName: 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_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`) };
|
||
}
|
||
|
||
/** vPoint แบบไม่บังคับ ≥ 0 — ใช้กับ STAMP ที่หมุน: position เป็นมุมกรอบ*ก่อนหมุน*
|
||
* ซึ่งติดลบ/เกิน 100 ได้โดยกรอบจริง (AABB ของกรอบหมุน) ยังอยู่ในหน้าครบ — เกณฑ์
|
||
* ความถูกต้องของพิกัดอยู่ที่ AABB ซึ่งผู้เรียกตรวจเอง (code review 2026-09-28) */
|
||
function vPointFinite(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>;
|
||
for (const axis of ["x", "y"] as const) {
|
||
if (typeof p[axis] !== "number" || !Number.isFinite(p[axis])) {
|
||
fieldError(`${field}.${axis}`, "เป็นตัวเลข");
|
||
}
|
||
}
|
||
return { x: p.x as number, y: p.y as number };
|
||
}
|
||
|
||
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);
|
||
// width (กล่องกว้างคงที่) เป็น optional — ไม่มี = shrink-to-fit ตามเดิม (แถวเดิม)
|
||
if (
|
||
d.width !== undefined &&
|
||
(typeof d.width !== "number" || !Number.isFinite(d.width) || d.width <= 0)
|
||
) {
|
||
fieldError("data.width", "เป็นตัวเลข > 0");
|
||
}
|
||
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 } : {}),
|
||
...(d.width !== undefined ? { width: d.width } : {}),
|
||
...(textboxNote !== undefined ? { note: textboxNote } : {}),
|
||
} satisfies TextboxData;
|
||
}
|
||
case "STAMP": {
|
||
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
|
||
// position = มุมกรอบก่อนหมุน — ติดลบได้เมื่อตรายางเอียง (ดู vPointFinite)
|
||
// เกณฑ์ "ไม่ติดลบ" ตรวจที่ AABB ของตรายางด้านล่างแทน position ตรง ๆ
|
||
const position = vPointFinite(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);
|
||
// มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ) optional — ไม่มี = ไม่เอียง
|
||
if (
|
||
d.rotation !== undefined &&
|
||
(typeof d.rotation !== "number" ||
|
||
!Number.isFinite(d.rotation) ||
|
||
d.rotation < -180 ||
|
||
d.rotation > 180)
|
||
) {
|
||
fieldError("data.rotation", "เป็นตัวเลข −180 ถึง 180");
|
||
}
|
||
// เกณฑ์พิกัดไม่ติดลบของ STAMP = AABB ของกรอบ (หมุนรอบกลางกรอบแล้ว): ตรายาง
|
||
// เอียงชิดขอบ มุมก่อนหมุนหลุดนอกหน้าได้โดยตัวตรายางจริงยังอยู่ในหน้าครบ —
|
||
// ตรวจ position ตรง ๆ แล้ว client clamp (ยึด AABB) ขัดกับ validator จนลาก
|
||
// ถึงขอบแล้วเด้งกลับ (code review 2026-09-28); ไม่มี rotation → AABB = rect
|
||
// เดิม เกณฑ์เดิมเป๊ะ — คำนวณด้วย estimate (pure ฝั่ง server ไม่มี DOM)
|
||
const stampAabb = rotatedRectAABB(
|
||
stampRect(
|
||
{ label, position, scale: d.scale, rotation: d.rotation ?? 0 },
|
||
{ width: 100, height: 100 },
|
||
),
|
||
);
|
||
// tolerance 2% ของหน้า: clamp ฝั่ง client ยึด measure จริงของ browser
|
||
// (measureStampText) ขณะที่นี่ยึด stampLayoutEstimate — ฟอนต์ต่อเครื่องทำให้
|
||
// จำนวนบรรทัด/AABB คลาดกันนิดหน่อย ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธเด้งกลับ
|
||
// (code review 2026-09-29) — ยอมให้ AABB ยื่นเกินขอบนิดหน่อยแลกกับไม่เด้ง
|
||
if (stampAabb.x < -2 || stampAabb.y < -2) {
|
||
fieldError("data.position", "เป็นพิกัดที่ตรายางยังอยู่ในหน้า (ไม่ติดลบ)");
|
||
}
|
||
return {
|
||
label,
|
||
position,
|
||
scale: d.scale,
|
||
...(color ? { color } : {}),
|
||
...(note !== undefined ? { note } : {}),
|
||
...(d.rotation !== undefined ? { rotation: d.rotation } : {}),
|
||
} 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 ─────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Invalidate Router Cache ฝั่ง client ของหน้า viewer + หน้าสรุปของไฟล์นี้
|
||
* หลัง action เขียน DB สำเร็จ — ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ Next.js
|
||
* ยังใช้ RSC payload เก่า (initialAnnotations ไม่รวมรายการใหม่) จนต้อง
|
||
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
||
*/
|
||
function revalidateFilePages(fileId: string) {
|
||
revalidatePath(`/annotate/${fileId}`);
|
||
revalidatePath(`/annotate/${fileId}/summary`);
|
||
}
|
||
|
||
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);
|
||
// id จาก client (ถ้ามา) = id ของ optimistic row — SSE event/dedupe ทุกฝั่งเทียบ id เดียวกัน
|
||
const id = typeof raw?.id === "string" ? vUuid(raw.id, "id") : undefined;
|
||
|
||
const values: typeof annotations.$inferInsert = {
|
||
fileId,
|
||
pageIndex,
|
||
userId,
|
||
userName,
|
||
type,
|
||
data,
|
||
};
|
||
if (id) values.id = id;
|
||
const [row] = await db.insert(annotations).values(values).returning();
|
||
const dto = toAnnotationDTO(row);
|
||
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
|
||
revalidateFilePages(fileId);
|
||
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,
|
||
});
|
||
revalidateFilePages(removed.fileId);
|
||
}
|
||
|
||
/**
|
||
* แก้ไข 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 });
|
||
revalidateFilePages(row.fileId);
|
||
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 });
|
||
revalidateFilePages(existing.fileId);
|
||
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 });
|
||
revalidateFilePages(parent.fileId);
|
||
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 });
|
||
revalidateFilePages(parent.fileId);
|
||
}
|
||
}
|
||
|
||
/** โน้ตย่อยของ 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 userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
|
||
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, userName, title, yOffset, parentId, note })
|
||
.returning();
|
||
return inserted;
|
||
});
|
||
const dto = toBookmarkDTO(row);
|
||
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
|
||
revalidateFilePages(fileId);
|
||
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 });
|
||
revalidateFilePages(row.fileId);
|
||
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,
|
||
});
|
||
revalidateFilePages(result.fileId);
|
||
// ลูกที่ถูกยกขึ้น 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 });
|
||
revalidateFilePages(v.fileId);
|
||
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);
|
||
}
|
||
|
||
// ─── Search bookmarks (N6 — requirement 5.3.12) ─────────────────────────────
|
||
|
||
export type SearchBookmarksInput = {
|
||
fileId: string;
|
||
/** คำค้น — ILIKE บน title + note (ไม่ส่ง/ว่าง = ไม่จำกัด ถ้ามีเงื่อนไขอื่น) */
|
||
q?: string;
|
||
/** ช่วงหน้า 1-based ตรงที่ผู้ใช้เห็น ("หน้า N") — ส่งฝั่งเดียวได้ */
|
||
pageFrom?: number;
|
||
pageTo?: number;
|
||
/** userId ของผู้บันทึก */
|
||
userId?: string;
|
||
/** ช่วงวันที่ที่บันทึก — YYYY-MM-DD ตีความเป็นเขตเวลา +07:00 คงที่ (ไทยไม่มี DST) */
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
};
|
||
|
||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
/** หน้า (1-based) ของเงื่อนไขค้นหา — optional, integer ≥ 1 */
|
||
function vSearchPage(v: unknown, field: string): number | undefined {
|
||
if (v === undefined || v === null || v === "") return undefined;
|
||
if (typeof v !== "number" || !Number.isInteger(v) || v < 1) {
|
||
fieldError(field, "เป็นจำนวนเต็ม ≥ 1");
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/** วันที่ (YYYY-MM-DD) ของเงื่อนไขค้นหา — optional, เช็ครูปแบบก่อน (แปลงเป็นเวลาทีหลัง swap) */
|
||
function vSearchDateString(v: unknown, field: string): string | undefined {
|
||
if (v === undefined || v === null || v === "") return undefined;
|
||
if (typeof v !== "string" || !DATE_ONLY_RE.test(v)) {
|
||
fieldError(field, "เป็นวันที่รูปแบบ YYYY-MM-DD");
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/** แปลง YYYY-MM-DD → ขอบเขตเวลา ต้นวัน/ปลายวัน เขตเวลา +07:00 คงที่ (ไทยไม่มี DST) */
|
||
function dateBound(v: string, endOfDay: boolean, field: string): Date {
|
||
const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00"}+07:00`);
|
||
if (Number.isNaN(d.getTime())) {
|
||
fieldError(field, "เป็นวันที่ที่มีอยู่จริง");
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* ค้นหา bookmark พร้อมเงื่อนไข — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง
|
||
* (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to)
|
||
* = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath
|
||
*/
|
||
export async function searchBookmarks(input: SearchBookmarksInput): Promise<BookmarkDTO[]> {
|
||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||
const fid = vUuid(raw?.fileId, "fileId");
|
||
const q = typeof raw?.q === "string" && raw.q.trim() !== ""
|
||
? vString(raw.q, "q", MAX_QUERY_CHARS)
|
||
: undefined;
|
||
const userId = typeof raw?.userId === "string" && raw.userId !== ""
|
||
? vString(raw.userId, "userId")
|
||
: undefined;
|
||
let pageFrom = vSearchPage(raw?.pageFrom, "pageFrom");
|
||
let pageTo = vSearchPage(raw?.pageTo, "pageTo");
|
||
if (pageFrom !== undefined && pageTo !== undefined && pageFrom > pageTo) {
|
||
[pageFrom, pageTo] = [pageTo, pageFrom];
|
||
}
|
||
// N6: swap กลับด้านต้องทำที่ "วัน" (string) ก่อนคำนวณขอบเขตเวลา — swap ที่ Date
|
||
// แล้วขอบเขตปลายวัน/ต้นวันจะติดกันเหลือ 1 ms (เช่น 16→15 กลายเป็น [15T23:59:59.999,
|
||
// 16T00:00]) จนช่วงกลับด้านได้ผล 0 รายการเสมอ
|
||
let fromDate = vSearchDateString(raw?.dateFrom, "dateFrom");
|
||
let toDate = vSearchDateString(raw?.dateTo, "dateTo");
|
||
if (fromDate !== undefined && toDate !== undefined && fromDate > toDate) {
|
||
[fromDate, toDate] = [toDate, fromDate];
|
||
}
|
||
const dateFrom = fromDate !== undefined ? dateBound(fromDate, false, "dateFrom") : undefined;
|
||
const dateTo = toDate !== undefined ? dateBound(toDate, true, "dateTo") : undefined;
|
||
|
||
if (
|
||
q === undefined &&
|
||
userId === undefined &&
|
||
pageFrom === undefined &&
|
||
pageTo === undefined &&
|
||
dateFrom === undefined &&
|
||
dateTo === undefined
|
||
) {
|
||
throw new Error("ต้องระบุเงื่อนไขอย่างน้อยหนึ่งอย่าง (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่)");
|
||
}
|
||
|
||
const esc = q !== undefined ? q.replace(/[\\%_]/g, (c) => `\\${c}`) : "";
|
||
const pattern = `%${esc}%`;
|
||
|
||
const rows = await db
|
||
.select()
|
||
.from(bookmarks)
|
||
.where(
|
||
and(
|
||
eq(bookmarks.fileId, fid),
|
||
// note เป็น NULL ได้ — OR(title ILIKE, NULL) = ผลของ title ตาม SQL semantics (ถูกต้อง)
|
||
q !== undefined
|
||
? or(
|
||
sql`${bookmarks.title} ILIKE ${pattern}`,
|
||
sql`${bookmarks.note} ILIKE ${pattern}`,
|
||
)
|
||
: undefined,
|
||
userId !== undefined ? eq(bookmarks.userId, userId) : undefined,
|
||
pageFrom !== undefined ? gte(bookmarks.pageIndex, pageFrom - 1) : undefined,
|
||
pageTo !== undefined ? lte(bookmarks.pageIndex, pageTo - 1) : undefined,
|
||
dateFrom !== undefined ? gte(bookmarks.createdAt, dateFrom) : undefined,
|
||
dateTo !== undefined ? lte(bookmarks.createdAt, dateTo) : undefined,
|
||
),
|
||
)
|
||
// ลำดับเดียวกับที่ต้นไม้ bookmark แสดงบนจอ (N5)
|
||
.orderBy(asc(bookmarks.pageIndex), asc(bookmarks.yOffset), asc(bookmarks.createdAt));
|
||
return rows.map(toBookmarkDTO);
|
||
}
|