eCourt-note/app/actions/annotations.ts
Your Name 8cc15ea75d
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
refactor(routes): move routes under /note, APIs under /api/note
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน
อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้
(Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ

(1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า
app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า
public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง
คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/…
ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้)

(2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note
ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม);
resolvePublicPdfPath root ที่ public/note (traversal guard เดิม);
middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads,
note/samples, note/pdf.worker, _next, fonts, favicon) + redirect
/note/login; next.config เพิ่ม redirects /docedit -> /docedit/note;
navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note;
pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload
เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard)

(3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง),
seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/
.dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง
disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree,
README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill)

(4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads ->
/app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์
ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image

(5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc,
vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker,
samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep
link), Playwright browser จริง (canvas + textLayer ไทย render, browser
ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer +
code-reviewer 3 รายงาน 0 blocking

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

1157 lines
53 KiB
TypeScript
Raw Permalink 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(`/note/annotate/${fileId}`);
revalidatePath(`/note/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);
}