eCourt-note/app/actions/annotations.ts
Your Name 651e354cad stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:56:00 +07:00

1157 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use server";
import { 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);
}