eCourt-note/app/actions/annotations.ts

974 lines
42 KiB
TypeScript
Raw Normal View History

"use server";
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
import { revalidatePath } from "next/cache";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
import { db } from "@/db";
import {
annotationReplies,
annotationVersions,
annotations,
bookmarks,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
toVersionDTO,
type AnnotationData,
type AnnotationDTO,
type AnnotationType,
type AnnotationVersionDTO,
type BookmarkDTO,
type CommentData,
type DrawData,
type DrawPen,
type HighlightData,
type Rect,
type RedactionData,
type ReplyDTO,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = {
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
};
export type CreateBookmarkInput = {
fileId: string;
pageIndex: number;
userId: string;
title: string;
/** % ของความสูงหน้า (0–100) */
yOffset: number;
/** N2: id ของหมุดหัวข้อแม่ (null = ระดับบนสุด) */
parentId?: string | null;
/** N2: โน้ตย่อย (null/ว่าง = ไม่มี) */
note?: string | null;
};
export type UpdateBookmarkInput = {
id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string;
note?: string | null;
/** ย้ายหัวข้อ: null = ระดับบนสุด / id = ใต้หัวข้อนั้น (ต้องเป็น root) */
parentId?: string | null;
};
// ─── Validation guards — ตรวจทุก field ก่อนแตะ DB (rules/data-layer.md) ──────
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ANNOTATION_TYPES: readonly AnnotationType[] = [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
];
const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้
const MAX_BOOKMARK_NOTE_CHARS = 200;
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
function fieldError(field: string, expect: string): never {
throw new Error(`input ไม่ถูกต้อง: ฟิลด์ ${field} ต้อง${expect}`);
}
function vString(v: unknown, field: string, maxChars?: number): string {
if (typeof v !== "string" || v.trim().length === 0) fieldError(field, "เป็น string ที่ไม่ว่าง");
if (maxChars !== undefined && v.length > maxChars) {
fieldError(field, `ยาวไม่เกิน ${maxChars} ตัวอักษร`);
}
return v;
}
/** โน้ตคำอธิบาย (note) เป็น optional — ค่าว่าง/ช่องว่างล้วนถือว่าไม่มีโน้ต */
function vNote(v: unknown): string | undefined {
if (v === undefined) return undefined;
if (typeof v !== "string") fieldError("data.note", "เป็น string");
if (v.length > MAX_TEXT_CHARS) {
fieldError("data.note", `ยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : undefined;
}
function vUuid(v: unknown, field: string): string {
if (typeof v !== "string" || !UUID_RE.test(v)) fieldError(field, "เป็น UUID");
return v;
}
function vPageIndex(v: unknown): number {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
fieldError("pageIndex", "เป็นจำนวนเต็ม ≥ 0");
}
return v;
}
/** ตัวเลขพิกัด % — finite และ ≥ 0 */
function vPercent(v: unknown, field: string): number {
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fieldError(field, "เป็นตัวเลข ≥ 0");
return v;
}
function vPoint(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
}
function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>;
return {
x: vPercent(r.x, `${field}.x`),
y: vPercent(r.y, `${field}.y`),
width: vPercent(r.width, `${field}.width`),
height: vPercent(r.height, `${field}.height`),
};
}
function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationData {
if (typeof v !== "object" || v === null) fieldError("data", `เป็น object ของประเภท ${type}`);
const d = v as Record<string, unknown>;
switch (type) {
case "HIGHLIGHT": {
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
if (!Array.isArray(d.rects) || d.rects.length === 0) {
fieldError("data.rects", "เป็น array ที่ไม่ว่าง");
}
const rects = d.rects.map((r, i) => vRect(r, `data.rects[${i}]`));
const note = vNote(d.note);
// รูปแบบการมาร์ก (style) เป็น optional — ไม่มี = highlight ทึบตามเดิม
if (
d.style !== undefined &&
d.style !== "highlight" &&
d.style !== "underline" &&
d.style !== "strikethrough" &&
d.style !== "squiggle"
) {
fieldError(
"data.style",
'เป็น "highlight" | "underline" | "strikethrough" | "squiggle"',
);
}
// ความทึบ (opacity 0–1) เป็น optional — ไม่มี = ทึบเต็ม (แถวเดิม)
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
return {
text,
color,
rects,
...(d.style !== undefined ? { style: d.style } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(note !== undefined ? { note } : {}),
} satisfies HighlightData;
}
case "DRAW": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (!Array.isArray(d.paths) || d.paths.length < 2) {
fieldError("data.paths", "เป็น array ที่มีจุดอย่างน้อย 2 จุด");
}
const paths = d.paths.map((p, i) => vPoint(p, `data.paths[${i}]`));
// ชนิดปากกา (pen) เป็น optional — ไม่มี = pencil (แถวเดิมก่อนมี highlighter)
let pen: DrawPen | undefined;
if (d.pen !== undefined) {
if (d.pen !== "pencil" && d.pen !== "highlighter") {
fieldError("data.pen", 'เป็น "pencil" หรือ "highlighter"');
}
pen = d.pen;
}
// ความทึบ (opacity 0–1) เป็น optional — แถวเดิมไม่มี = ตามค่า preset ของ pen
let opacity: number | undefined;
if (d.opacity !== undefined) {
if (
typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
opacity = d.opacity;
}
// F7/E1: widths (pressure) เป็น optional — ถ้ามีต้องยาวเท่า paths พอดี
let widths: number[] | undefined;
if (d.widths !== undefined) {
if (!Array.isArray(d.widths) || d.widths.length !== paths.length) {
fieldError("data.widths", "เป็น array ความหนาที่ยาวเท่า data.paths");
}
widths = d.widths.map((w, i) => vPercent(w, `data.widths[${i}]`));
}
const note = vNote(d.note);
return {
strokeColor,
strokeWidth,
paths,
...(pen ? { pen } : {}),
...(opacity !== undefined ? { opacity } : {}),
...(widths ? { widths } : {}),
...(note !== undefined ? { note } : {}),
} satisfies DrawData;
}
case "COMMENT": {
const position = vPoint(d.position, "data.position");
const message = vString(d.message, "data.message", MAX_TEXT_CHARS);
return { position, message } satisfies CommentData;
}
case "REDACTION": {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return { rect, ...(note !== undefined ? { note } : {}) } satisfies RedactionData;
}
case "TEXTBOX": {
const position = vPoint(d.position, "data.position");
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
// fontSize เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.fontSize !== "number" ||
!Number.isFinite(d.fontSize) ||
d.fontSize <= 0
) {
fieldError("data.fontSize", "เป็นตัวเลข > 0");
}
// bold/opacity/align เป็น optional — ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย (แถวเดิม)
if (d.bold !== undefined && typeof d.bold !== "boolean") {
fieldError("data.bold", "เป็น boolean");
}
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
if (
d.align !== undefined &&
d.align !== "left" &&
d.align !== "center" &&
d.align !== "right"
) {
fieldError("data.align", 'เป็น "left" | "center" | "right"');
}
// segments (ตัวหนาเฉพาะส่วน) เป็น optional array ของ { text, bold? }
if (d.segments !== undefined) {
if (!Array.isArray(d.segments)) {
fieldError("data.segments", "เป็น array ของ { text, bold? }");
}
for (const seg of d.segments) {
if (seg === null || typeof seg !== "object") {
fieldError("data.segments", "เป็น object { text, bold? }");
}
if (typeof (seg as { text?: unknown })?.text !== "string") {
fieldError("data.segments[].text", "เป็น string");
}
if (
(seg as { bold?: unknown })?.bold !== undefined &&
typeof (seg as { bold?: unknown }).bold !== "boolean"
) {
fieldError("data.segments[].bold", "เป็น boolean");
}
}
// FIX-4 (ต่อ): ความยาวรวมของทุกส่วนต้องเข้าขอบเดียวกับ data.text — ไม่งั้น
// เรียก action ตรง ๆ ด้วย segments ยักษ์ (แยกจาก text ที่ส่งมาสั้น) ก็ยังฝัง
// ข้อความขนาดหลาย MB ลง JSONB + SSE event ใหญ่ผิดปกติได้เหมือนเดิม
const segTotal = d.segments
.map((seg) => (seg as { text: string }).text)
.join("").length;
if (segTotal > MAX_TEXT_CHARS) {
fieldError("data.segments", `ข้อความรวมทุกส่วนยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
}
const textboxNote = vNote(d.note);
return {
position,
text,
color,
fontSize: d.fontSize,
...(d.bold ? { bold: true } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(d.align !== undefined ? { align: d.align } : {}),
...(d.segments !== undefined ? { segments: d.segments } : {}),
...(textboxNote !== undefined ? { note: textboxNote } : {}),
} satisfies TextboxData;
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
const position = vPoint(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
!Number.isFinite(d.scale) ||
d.scale <= 0
) {
fieldError("data.scale", "เป็นตัวเลข > 0");
}
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note);
return {
label,
position,
scale: d.scale,
...(color ? { color } : {}),
...(note !== undefined ? { note } : {}),
} satisfies StampData;
}
case "SHAPE": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (typeof d.shape !== "string" || !SHAPE_KINDS.includes(d.shape)) {
return fieldError("data.shape", `เป็นหนึ่งใน ${SHAPE_KINDS.join(" | ")}`);
}
if (d.shape === "rect" || d.shape === "ellipse") {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return {
shape: d.shape as "rect" | "ellipse",
rect,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
const from = vPoint(d.from, "data.from");
const to = vPoint(d.to, "data.to");
const note = vNote(d.note);
return {
shape: d.shape as "line" | "arrow",
from,
to,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
}
}
// ─── Actions ─────────────────────────────────────────────────────────────────
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
/**
* 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);
const [row] = await db
.insert(annotations)
.values({ fileId, pageIndex, userId, userName, type, data })
.returning();
const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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,
});
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if (
typeof raw?.yOffset !== "number" ||
!Number.isFinite(raw.yOffset) ||
raw.yOffset < 0 ||
raw.yOffset > 100
) {
fieldError("yOffset", "เป็นตัวเลข 0–100");
}
const note = vBookmarkNote(raw?.note);
// capture เป็นค่าคงที่ก่อนเข้า closure ของ transaction (narrowing ไม่ไหลเข้า callback)
const yOffset = raw.yOffset as number;
const row = await db.transaction(async (tx) => {
// N2: ถ้าระบุแม่ — ต้องอยู่ไฟล์เดียวกันและลึกไม่เกิน MAX_DEPTH-1 ระดับ
// (สร้างเป็นลูกแล้วรวมกันต้อง ≤ 3) — ตรวจพร้อม lock ทั้งสายบรรพบุรุษของแม่
// ภายใน tx เดียวกับ insert เพื่อ serialize กับ updateBookmark (ย้าย) และ
// deleteBookmark (ลบแม่ + re-parent) กัน race เกินความลึกหรือ sub กำพร้า
let parentId: string | null = null;
if (raw?.parentId !== undefined && raw?.parentId !== null && raw?.parentId !== "") {
const pid = vUuid(raw.parentId, "parentId");
const info = await lockedDepth(tx, pid);
if (info.fileId !== fileId) {
throw new Error("สร้างไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (info.depth >= BOOKMARK_MAX_DEPTH) {
throw new Error(`สร้างไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
const [inserted] = await tx
.insert(bookmarks)
.values({ fileId, pageIndex, userId, title, yOffset, parentId, note })
.returning();
return inserted;
});
const dto = toBookmarkDTO(row);
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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,
});
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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 });
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00
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);
}