Add application codebase: PDF annotation for e-Court

Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 14:29:40 +07:00
parent 6fd0c3174f
commit eddcec28c7
60 changed files with 15055 additions and 0 deletions

951
app/actions/annotations.ts Normal file
View file

@ -0,0 +1,951 @@
"use server";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
import { db } from "@/db";
import {
annotationReplies,
annotationVersions,
annotations,
bookmarks,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
toVersionDTO,
type AnnotationData,
type AnnotationDTO,
type AnnotationType,
type AnnotationVersionDTO,
type BookmarkDTO,
type CommentData,
type DrawData,
type DrawPen,
type HighlightData,
type Rect,
type RedactionData,
type ReplyDTO,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = {
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
};
export type CreateBookmarkInput = {
fileId: string;
pageIndex: number;
userId: string;
title: string;
/** % ของความสูงหน้า (0–100) */
yOffset: number;
/** N2: id ของหมุดหัวข้อแม่ (null = ระดับบนสุด) */
parentId?: string | null;
/** N2: โน้ตย่อย (null/ว่าง = ไม่มี) */
note?: string | null;
};
export type UpdateBookmarkInput = {
id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string;
note?: string | null;
/** ย้ายหัวข้อ: null = ระดับบนสุด / id = ใต้หัวข้อนั้น (ต้องเป็น root) */
parentId?: string | null;
};
// ─── Validation guards — ตรวจทุก field ก่อนแตะ DB (rules/data-layer.md) ──────
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ANNOTATION_TYPES: readonly AnnotationType[] = [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
];
const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้
const MAX_BOOKMARK_NOTE_CHARS = 200;
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
function fieldError(field: string, expect: string): never {
throw new Error(`input ไม่ถูกต้อง: ฟิลด์ ${field} ต้อง${expect}`);
}
function vString(v: unknown, field: string, maxChars?: number): string {
if (typeof v !== "string" || v.trim().length === 0) fieldError(field, "เป็น string ที่ไม่ว่าง");
if (maxChars !== undefined && v.length > maxChars) {
fieldError(field, `ยาวไม่เกิน ${maxChars} ตัวอักษร`);
}
return v;
}
/** โน้ตคำอธิบาย (note) เป็น optional — ค่าว่าง/ช่องว่างล้วนถือว่าไม่มีโน้ต */
function vNote(v: unknown): string | undefined {
if (v === undefined) return undefined;
if (typeof v !== "string") fieldError("data.note", "เป็น string");
if (v.length > MAX_TEXT_CHARS) {
fieldError("data.note", `ยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : undefined;
}
function vUuid(v: unknown, field: string): string {
if (typeof v !== "string" || !UUID_RE.test(v)) fieldError(field, "เป็น UUID");
return v;
}
function vPageIndex(v: unknown): number {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
fieldError("pageIndex", "เป็นจำนวนเต็ม ≥ 0");
}
return v;
}
/** ตัวเลขพิกัด % — finite และ ≥ 0 */
function vPercent(v: unknown, field: string): number {
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fieldError(field, "เป็นตัวเลข ≥ 0");
return v;
}
function vPoint(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
}
function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>;
return {
x: vPercent(r.x, `${field}.x`),
y: vPercent(r.y, `${field}.y`),
width: vPercent(r.width, `${field}.width`),
height: vPercent(r.height, `${field}.height`),
};
}
function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationData {
if (typeof v !== "object" || v === null) fieldError("data", `เป็น object ของประเภท ${type}`);
const d = v as Record<string, unknown>;
switch (type) {
case "HIGHLIGHT": {
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
if (!Array.isArray(d.rects) || d.rects.length === 0) {
fieldError("data.rects", "เป็น array ที่ไม่ว่าง");
}
const rects = d.rects.map((r, i) => vRect(r, `data.rects[${i}]`));
const note = vNote(d.note);
// รูปแบบการมาร์ก (style) เป็น optional — ไม่มี = highlight ทึบตามเดิม
if (
d.style !== undefined &&
d.style !== "highlight" &&
d.style !== "underline" &&
d.style !== "strikethrough" &&
d.style !== "squiggle"
) {
fieldError(
"data.style",
'เป็น "highlight" | "underline" | "strikethrough" | "squiggle"',
);
}
// ความทึบ (opacity 0–1) เป็น optional — ไม่มี = ทึบเต็ม (แถวเดิม)
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
return {
text,
color,
rects,
...(d.style !== undefined ? { style: d.style } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(note !== undefined ? { note } : {}),
} satisfies HighlightData;
}
case "DRAW": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (!Array.isArray(d.paths) || d.paths.length < 2) {
fieldError("data.paths", "เป็น array ที่มีจุดอย่างน้อย 2 จุด");
}
const paths = d.paths.map((p, i) => vPoint(p, `data.paths[${i}]`));
// ชนิดปากกา (pen) เป็น optional — ไม่มี = pencil (แถวเดิมก่อนมี highlighter)
let pen: DrawPen | undefined;
if (d.pen !== undefined) {
if (d.pen !== "pencil" && d.pen !== "highlighter") {
fieldError("data.pen", 'เป็น "pencil" หรือ "highlighter"');
}
pen = d.pen;
}
// ความทึบ (opacity 0–1) เป็น optional — แถวเดิมไม่มี = ตามค่า preset ของ pen
let opacity: number | undefined;
if (d.opacity !== undefined) {
if (
typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
opacity = d.opacity;
}
// F7/E1: widths (pressure) เป็น optional — ถ้ามีต้องยาวเท่า paths พอดี
let widths: number[] | undefined;
if (d.widths !== undefined) {
if (!Array.isArray(d.widths) || d.widths.length !== paths.length) {
fieldError("data.widths", "เป็น array ความหนาที่ยาวเท่า data.paths");
}
widths = d.widths.map((w, i) => vPercent(w, `data.widths[${i}]`));
}
const note = vNote(d.note);
return {
strokeColor,
strokeWidth,
paths,
...(pen ? { pen } : {}),
...(opacity !== undefined ? { opacity } : {}),
...(widths ? { widths } : {}),
...(note !== undefined ? { note } : {}),
} satisfies DrawData;
}
case "COMMENT": {
const position = vPoint(d.position, "data.position");
const message = vString(d.message, "data.message", MAX_TEXT_CHARS);
return { position, message } satisfies CommentData;
}
case "REDACTION": {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return { rect, ...(note !== undefined ? { note } : {}) } satisfies RedactionData;
}
case "TEXTBOX": {
const position = vPoint(d.position, "data.position");
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
// fontSize เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.fontSize !== "number" ||
!Number.isFinite(d.fontSize) ||
d.fontSize <= 0
) {
fieldError("data.fontSize", "เป็นตัวเลข > 0");
}
// bold/opacity/align เป็น optional — ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย (แถวเดิม)
if (d.bold !== undefined && typeof d.bold !== "boolean") {
fieldError("data.bold", "เป็น boolean");
}
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
if (
d.align !== undefined &&
d.align !== "left" &&
d.align !== "center" &&
d.align !== "right"
) {
fieldError("data.align", 'เป็น "left" | "center" | "right"');
}
// segments (ตัวหนาเฉพาะส่วน) เป็น optional array ของ { text, bold? }
if (d.segments !== undefined) {
if (!Array.isArray(d.segments)) {
fieldError("data.segments", "เป็น array ของ { text, bold? }");
}
for (const seg of d.segments) {
if (seg === null || typeof seg !== "object") {
fieldError("data.segments", "เป็น object { text, bold? }");
}
if (typeof (seg as { text?: unknown })?.text !== "string") {
fieldError("data.segments[].text", "เป็น string");
}
if (
(seg as { bold?: unknown })?.bold !== undefined &&
typeof (seg as { bold?: unknown }).bold !== "boolean"
) {
fieldError("data.segments[].bold", "เป็น boolean");
}
}
// FIX-4 (ต่อ): ความยาวรวมของทุกส่วนต้องเข้าขอบเดียวกับ data.text — ไม่งั้น
// เรียก action ตรง ๆ ด้วย segments ยักษ์ (แยกจาก text ที่ส่งมาสั้น) ก็ยังฝัง
// ข้อความขนาดหลาย MB ลง JSONB + SSE event ใหญ่ผิดปกติได้เหมือนเดิม
const segTotal = d.segments
.map((seg) => (seg as { text: string }).text)
.join("").length;
if (segTotal > MAX_TEXT_CHARS) {
fieldError("data.segments", `ข้อความรวมทุกส่วนยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
}
const textboxNote = vNote(d.note);
return {
position,
text,
color,
fontSize: d.fontSize,
...(d.bold ? { bold: true } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(d.align !== undefined ? { align: d.align } : {}),
...(d.segments !== undefined ? { segments: d.segments } : {}),
...(textboxNote !== undefined ? { note: textboxNote } : {}),
} satisfies TextboxData;
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
const position = vPoint(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
!Number.isFinite(d.scale) ||
d.scale <= 0
) {
fieldError("data.scale", "เป็นตัวเลข > 0");
}
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note);
return {
label,
position,
scale: d.scale,
...(color ? { color } : {}),
...(note !== undefined ? { note } : {}),
} satisfies StampData;
}
case "SHAPE": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (typeof d.shape !== "string" || !SHAPE_KINDS.includes(d.shape)) {
return fieldError("data.shape", `เป็นหนึ่งใน ${SHAPE_KINDS.join(" | ")}`);
}
if (d.shape === "rect" || d.shape === "ellipse") {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return {
shape: d.shape as "rect" | "ellipse",
rect,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
const from = vPoint(d.from, "data.from");
const to = vPoint(d.to, "data.to");
const note = vNote(d.note);
return {
shape: d.shape as "line" | "arrow",
from,
to,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
}
}
// ─── Actions ─────────────────────────────────────────────────────────────────
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
}
const type = raw.type as AnnotationType;
const data = validateAnnotationData(type, raw?.data);
const [row] = await db
.insert(annotations)
.values({ fileId, pageIndex, userId, userName, type, data })
.returning();
const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
return dto;
}
/**
* ลบได้เฉพาะรายการของตัวเอง (userId ตรงกัน) เท่านั้น
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง
*/
export async function deleteAnnotation(id: string, userId: string): Promise<void> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const removed = await db.transaction(async (tx) => {
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
const [existing] = await tx
.select()
.from(annotations)
.where(and(eq(annotations.id, annId), eq(annotations.userId, uid)))
.for("update");
if (!existing) return null;
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "delete",
});
const [del] = await tx
.delete(annotations)
.where(eq(annotations.id, annId))
.returning({ id: annotations.id, fileId: annotations.fileId });
return del ?? null;
});
if (!removed) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ annotation นี้ หรือไม่ใช่รายการของคุณ");
}
publishAnnotateEvent(removed.fileId, {
table: "annotations",
action: "delete",
id: annId,
});
}
/**
* แก้ไข data ของ annotation ที่มีอยู่ (PLAN-FEAT.md §3 F1)
* - เจ้าของเท่านั้น (D9) และห้ามเปลี่ยน type/pageIndex — validate ตาม type เดิมของ row
* - ย้าย/ปรับขนาดได้ในหน้าเดียวกันเท่านั้น (pageIndex ไม่ถูกแตะ)
* - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
*/
export async function updateAnnotation(
id: string,
userId: string,
data: AnnotationData,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, annId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// validate ด้วย validator เดิมตาม type ของ row — data ผิด variant ของ type นั้นจะถูกปฏิเสธ
const validated = validateAnnotationData(existing.type, data);
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(row.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
/**
* F10/A5: toggle สถานะ "จัดการแล้ว" — **ใครก็ได้** (D6: คลายคืนได้ ไม่ทำลายข้อมูล
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
*/
export async function setAnnotationResolved(
id: string,
resolved: boolean,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
if (typeof resolved !== "boolean") {
fieldError("resolved", "เป็น boolean");
}
const [existing] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annId));
if (!existing) {
throw new Error("ไม่พบ annotation นี้");
}
const [row] = await db
.update(annotations)
.set({ resolved, resolvedAt: resolved ? new Date() : null, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
if (!row) {
throw new Error("ตั้งสถานะไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(existing.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
// ─── Replies (F9/A4 — D2: ตารางแยก annotation_replies, FK CASCADE) ──────────
export type CreateReplyInput = {
annotationId: string;
userId: string;
userName: string;
message: string;
};
export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const annotationId = vUuid(raw?.annotationId, "annotationId");
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
const message = vString(raw?.message, "message", MAX_TEXT_CHARS);
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annotationId));
if (!parent) {
throw new Error("ตอบไม่ได้: ไม่พบ comment ต้นทาง");
}
const [row] = await db
.insert(annotationReplies)
.values({ annotationId, userId, userName, message })
.returning();
const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
return dto;
}
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */
export async function deleteReply(id: string, userId: string): Promise<void> {
const replyId = vUuid(id, "id");
const uid = vString(userId, "userId");
const [existing] = await db
.select({ annotationId: annotationReplies.annotationId })
.from(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)));
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
// หา fileId ของแม่ไว้ publish (แม่หายไปแล้ว = CASCADE ลบ reply ทิ้ง แทบไม่เกิด)
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, existing.annotationId));
const removed = await db
.delete(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)))
.returning({ id: annotationReplies.id });
if (removed.length === 0) {
// race: ถูกลบไปแล้วระหว่าง select → delete — ไม่ publish event ทับ
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
if (parent) {
publishAnnotateEvent(parent.fileId, { table: "replies", action: "delete", id: replyId });
}
}
/** โน้ตย่อยของ bookmark — ว่าง/ช่องว่างล้วน = null (ไม่มีโน้ต) */
function vBookmarkNote(v: unknown): string | null {
if (v === undefined || v === null) return null;
if (typeof v !== "string") fieldError("note", "เป็น string");
if (v.length > MAX_BOOKMARK_NOTE_CHARS) {
fieldError("note", `ยาวไม่เกิน ${MAX_BOOKMARK_NOTE_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : null;
}
// ─── N2: โครงสร้างต้นไม้ bookmark (3 ระดับ) — helper ทุกตัว lock แถวที่เดินผ่าน
// เพื่อ serialize กับ create/update/delete ที่แตะโหนดเดียวกัน (กัน race ความลึก/orphan)
/** ความลึกสูงสุดของต้นไม้ bookmark (root = ระดับ 1) */
const BOOKMARK_MAX_DEPTH = 3;
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
/** cap การเดินต้นไม้ — ปกติลึกสุด 3 ระดับ ถ้าเกินนี้แปลว่าข้อมูลวนซ้ำ (cycle) ให้ fail ดีกว่าลูปไม่รู้จบ */
const TREE_WALK_LIMIT = 100;
/** ความลึกของ bookmark (root = 1) + ไฟล์ของตัวมัน — เดินขึ้นหาบรรพบุรุษพร้อม lock ทุกตัว */
async function lockedDepth(tx: Tx, id: string): Promise<{ depth: number; fileId: string }> {
let depth = 1;
let cur = id;
for (;;) {
if (depth > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const [row] = await tx
.select({ fileId: bookmarks.fileId, parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row) {
throw new Error("ไม่พบหมุดหัวข้อในลำดับชั้น (อาจถูกลบไปแล้ว)");
}
if (row.parentId === null) return { depth, fileId: row.fileId };
depth += 1;
cur = row.parentId;
}
}
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) — เดินลงหาลูกทุกตัวพร้อม lock (กัน create แข่งเพิ่มความลึกระหว่างย้าย) */
async function lockedSubtreeHeight(tx: Tx, id: string, level = 1): Promise<number> {
if (level > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const kids = await tx
.select({ id: bookmarks.id })
.from(bookmarks)
.where(eq(bookmarks.parentId, id))
.for("update");
let height = 1;
for (const k of kids) {
height = Math.max(height, 1 + (await lockedSubtreeHeight(tx, k.id, level + 1)));
}
return height;
}
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) — ใช้กันการย้ายเข้า subtree ของตัวเอง */
async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boolean> {
let cur = id;
for (let steps = 0; steps <= TREE_WALK_LIMIT; steps += 1) {
const [row] = await tx
.select({ parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row || row.parentId === null) return false;
if (row.parentId === ancestorId) return true;
cur = row.parentId;
}
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
export async function createBookmark(input: CreateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if (
typeof raw?.yOffset !== "number" ||
!Number.isFinite(raw.yOffset) ||
raw.yOffset < 0 ||
raw.yOffset > 100
) {
fieldError("yOffset", "เป็นตัวเลข 0–100");
}
const note = vBookmarkNote(raw?.note);
// capture เป็นค่าคงที่ก่อนเข้า closure ของ transaction (narrowing ไม่ไหลเข้า callback)
const yOffset = raw.yOffset as number;
const row = await db.transaction(async (tx) => {
// N2: ถ้าระบุแม่ — ต้องอยู่ไฟล์เดียวกันและลึกไม่เกิน MAX_DEPTH-1 ระดับ
// (สร้างเป็นลูกแล้วรวมกันต้อง ≤ 3) — ตรวจพร้อม lock ทั้งสายบรรพบุรุษของแม่
// ภายใน tx เดียวกับ insert เพื่อ serialize กับ updateBookmark (ย้าย) และ
// deleteBookmark (ลบแม่ + re-parent) กัน race เกินความลึกหรือ sub กำพร้า
let parentId: string | null = null;
if (raw?.parentId !== undefined && raw?.parentId !== null && raw?.parentId !== "") {
const pid = vUuid(raw.parentId, "parentId");
const info = await lockedDepth(tx, pid);
if (info.fileId !== fileId) {
throw new Error("สร้างไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (info.depth >= BOOKMARK_MAX_DEPTH) {
throw new Error(`สร้างไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
const [inserted] = await tx
.insert(bookmarks)
.values({ fileId, pageIndex, userId, title, yOffset, parentId, note })
.returning();
return inserted;
});
const dto = toBookmarkDTO(row);
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
return dto;
}
/**
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง
*/
export async function updateBookmark(input: UpdateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const bmId = vUuid(raw?.id, "id");
const uid = vString(raw?.userId, "userId");
const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// ย้ายหัวข้อ (parentId) — validate 3 ข้อ: แม่อยู่ไฟล์เดียวกัน, ความลึกแม่ + ความสูง
// subtree ของตัวเองรวมกัน ≤ MAX_DEPTH, และห้ามย้ายเข้า subtree ของตัวเอง (cycle)
let parentId: string | null | undefined;
if (raw?.parentId !== undefined) {
if (raw.parentId === null || raw.parentId === "") {
parentId = null;
} else {
const pid = vUuid(raw.parentId, "parentId");
if (pid === bmId) {
throw new Error("ย้ายไม่ได้: ห้ามกำหนดตัวเองเป็นแม่");
}
const info = await lockedDepth(tx, pid);
if (info.fileId !== existing.fileId) {
throw new Error("ย้ายไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (await isDescendant(tx, pid, bmId)) {
throw new Error("ย้ายไม่ได้: ห้ามย้ายเข้าไปใน subtree ของตัวเอง");
}
const height = await lockedSubtreeHeight(tx, bmId);
if (info.depth + height > BOOKMARK_MAX_DEPTH) {
throw new Error(`ย้ายไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
}
const [updated] = await tx
.update(bookmarks)
.set({
...(title !== undefined ? { title } : {}),
...(note !== undefined ? { note } : {}),
...(parentId !== undefined ? { parentId } : {}),
})
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toBookmarkDTO(row);
publishAnnotateEvent(row.fileId, { table: "bookmarks", action: "update", row: dto });
return dto;
}
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */
export async function deleteBookmark(id: string, userId: string): Promise<void> {
const bmId = vUuid(id, "id");
const uid = vString(userId, "userId");
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId })
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("ลบไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// re-parent ลูกขึ้นระดับบนสุดก่อนลบ (parentId ไม่มี FK — ต้องจัดการเอง)
const children = await tx
.update(bookmarks)
.set({ parentId: null })
.where(eq(bookmarks.parentId, bmId))
.returning();
const removed = await tx
.delete(bookmarks)
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning({ id: bookmarks.id });
return { fileId: existing.fileId, children, removed };
});
if (result.removed.length === 0) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → delete
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "delete",
id: bmId,
});
// ลูกที่ถูกยกขึ้น root → update event ต่อตัว ให้ client อื่นย้ายตำแหน่งตาม
for (const child of result.children) {
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "update",
row: toBookmarkDTO(child),
});
}
}
// ─── Version History (F19/A7) ────────────────────────────────────────────────
/** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
export async function listVersions(
annotationId: string,
): Promise<AnnotationVersionDTO[]> {
const annId = vUuid(annotationId, "annotationId");
const rows = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.annotationId, annId))
.orderBy(desc(annotationVersions.createdAt));
return rows.map(toVersionDTO);
}
/**
* กู้คืนจาก snapshot:
* - annotation ต้นทาง**ยังอยู่** → เขียนข้อมูลของ version กลับลง row เดิม
* (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ — กู้คืนกลับไป-มาได้)
* แล้ว publish update event — เดิมสร้างสำเนา id ใหม่ทุกครั้งจนเกิดรายการซ้ำบนหน้า
* - ต้นทางถูกลบไปแล้ว → สร้าง annotation ใหม่ **id ใหม่** บนหน้าเดิม เจ้าของเดิม
* (D8: version รอดจาก CASCADE ของแม่) + publish create event ให้ทุก client
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
*/
export async function restoreVersion(
versionId: string,
userId: string,
): Promise<AnnotationDTO> {
const vId = vUuid(versionId, "versionId");
const uid = vString(userId, "userId");
const [v] = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.id, vId));
if (!v) {
throw new Error("ไม่พบ version นี้");
}
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, v.annotationId))
.for("update");
if (existing) {
if (existing.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// snapshot สถานะปัจจุบันก่อนเขียนกลับ — ผู้ใช้ย้อนกลับไป-กลับมาได้เหมือน undo
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
// validate ตาม type ของ row จริง — version อาจมาจาก type เดียวกันเสมอ (D8) แต่กันไว้
const validated = validateAnnotationData(existing.type, v.data);
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, existing.id))
.returning();
return { action: "update" as const, row: updated };
}
// ต้นทางถูกลบไปแล้ว — เจ้าของ = userId ของ snapshot (D9)
if (v.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
const [row] = await tx
.insert(annotations)
.values({
fileId: v.fileId,
pageIndex: v.pageIndex,
userId: v.userId,
userName: v.userName,
type: v.type,
data: v.data,
})
.returning();
return { action: "create" as const, row };
});
const dto = toAnnotationDTO(result.row);
publishAnnotateEvent(v.fileId, { table: "annotations", action: result.action, row: dto });
return dto;
}
// ─── Search (F14/D1) ─────────────────────────────────────────────────────────
/**
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern
*/
export async function searchAnnotations(fileId: string, q: string): Promise<AnnotationDTO[]> {
const fid = vUuid(fileId, "fileId");
const query = vString(q, "q", MAX_QUERY_CHARS);
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
const pattern = `%${esc}%`;
const rows = await db
.select()
.from(annotations)
.where(
and(
eq(annotations.fileId, fid),
or(
sql`${annotations.data}->>'text' ILIKE ${pattern}`,
sql`${annotations.data}->>'message' ILIKE ${pattern}`,
sql`${annotations.data}->>'note' ILIKE ${pattern}`,
),
),
)
.orderBy(asc(annotations.createdAt));
return rows.map(toAnnotationDTO);
}

View file

@ -0,0 +1,63 @@
import { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { db } from "@/db";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
} from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export default async function AnnotatePage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const [annotationRows, bookmarkRows] = await Promise.all([
db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(asc(bookmarks.createdAt)),
]);
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
const annotationIds = annotationRows.map((r) => r.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
return (
<PDFViewer
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
initialAnnotations={annotationRows.map(toAnnotationDTO)}
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
initialReplies={replyRows.map(toReplyDTO)}
/>
);
}

View file

@ -0,0 +1,118 @@
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { ArrowLeft, MapPin } from "lucide-react";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้)
// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/<id>?ann=<uuid>` (F12)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export default async function SummaryPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const dtos = rows.map(toAnnotationDTO);
// จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ)
const byPage = new Map<number, AnnotationDTO[]>();
for (const a of dtos) {
const list = byPage.get(a.pageIndex);
if (list) list.push(a);
else byPage.set(a.pageIndex, [a]);
}
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
return (
<main className="mx-auto max-w-3xl p-6">
<div className="flex flex-wrap items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
สรุปหมายเหตุ — {file.title}
</h1>
<Link
href={`/annotate/${id}`}
className="flex items-center gap-1 rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
<ArrowLeft className="h-3.5 w-3.5" />
กลับหน้าอ่าน
</Link>
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
<a
href={`/api/annotations/export-csv?fileId=${id}`}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Export CSV
</a>
</div>
{pages.length === 0 ? (
<p className="mt-10 rounded border border-dashed p-6 text-center text-sm text-slate-500">
ยังไม่มี annotation ในไฟล์นี้
</p>
) : (
pages.map(([pageIndex, items]) => (
<section key={pageIndex} className="mt-6">
<h2 className="border-b border-slate-200 pb-1 text-sm font-semibold">
หน้า {pageIndex + 1}
<span className="ml-2 text-xs font-normal text-slate-400">
{items.length} รายการ
</span>
</h2>
<ul className="mt-2 divide-y divide-slate-100">
{items.map((a) => (
<li key={a.id} className="flex items-start gap-3 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs">
<span className="font-medium">{annotationTypeLabel(a)}</span>
{a.type === "COMMENT" && a.resolved && (
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">
จัดการแล้ว
</span>
)}
<span className="ml-2 text-slate-400">{a.userName}</span>
</span>
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm">
{annotationPreviewText(a)}
</span>
{annotationNote(a) && (
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs text-slate-500">
โน้ต: {annotationNote(a)}
</span>
)}
</span>
{/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */}
<Link
href={`/annotate/${id}?ann=${a.id}`}
className="flex shrink-0 items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100"
>
<MapPin className="h-3 w-3" />
ไปตำแหน่ง
</Link>
</li>
))}
</ul>
</section>
))
)}
</main>
);
}

View file

@ -0,0 +1,55 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
import { csvField } from "@/utils/csv";
import { encodeRFC5987 } from "@/utils/pdf-export";
// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15)
// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field
// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"];
const lines = rows.map((row) => {
const a = toAnnotationDTO(row);
return [
String(a.pageIndex + 1),
annotationTypeLabel(a),
a.userName,
annotationPreviewText(a),
annotationNote(a),
new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }),
]
.map(csvField)
.join(",");
});
//  = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ
const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`;
const filename = encodeRFC5987(`${file.title}-summary.csv`);
return new Response(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -0,0 +1,450 @@
import fs from "node:fs";
import path from "node:path";
import fontkit from "@pdf-lib/fontkit";
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
annotations,
files,
toAnnotationDTO,
type AnnotationType,
type CommentData,
type DrawData,
type HighlightData,
type RedactionData,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
wrapTextByWidth,
type Rot,
type StyledRun,
} from "@/utils/pdf-export";
import type { Rect } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, STAMP_FONT_RATIO, effectiveDrawOpacity } from "@/utils/annotation-display";
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
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 SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4)
const TYPE_ORDER: Record<AnnotationType, number> = {
HIGHLIGHT: 0,
DRAW: 1,
SHAPE: 2,
REDACTION: 3,
COMMENT: 4,
TEXTBOX: 5,
STAMP: 6,
};
export async function GET(req: Request) {
const url = new URL(req.url);
const fileId = url.searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
let srcBytes: Buffer;
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) {
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
}
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
}
const doc = await PDFDocument.load(srcBytes);
doc.registerFontkit(fontkit);
// D5: ฟอนต์ไทย optional — ไม่มีก็ export สำเร็จ (COMMENT เป็นหมุดอย่างเดียว)
let thaiFont: PDFFont | null = null;
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
if (fs.existsSync(fontPath)) {
try {
thaiFont = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun ไม่สำเร็จ — ไปทาง fallback หมุดอย่างเดียว", err);
thaiFont = null;
}
}
// TEXTBOX ตัวหนา — optional เช่นกัน: ไม่มี Sarabun-Bold.ttf ก็ fallback เป็น Regular
// (บนจอ font-weight 700 อยู่แล้ว แต่ export ต้องมีไฟล์น้ำหนักหนาจริงจึงตรงกัน)
let thaiBoldFont: PDFFont | null = null;
const boldFontPath = path.join(process.cwd(), "public", "fonts", "Sarabun-Bold.ttf");
if (fs.existsSync(boldFontPath)) {
try {
thaiBoldFont = await doc.embedFont(fs.readFileSync(boldFontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun-Bold ไม่สำเร็จ — ตัวหนา fallback เป็น Regular", err);
thaiBoldFont = null;
}
}
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.createdAt));
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = rows
.map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
for (const a of dtos) {
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
const pageSize = page.getSize();
const size: Size = { width: pageSize.width, height: pageSize.height };
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
const display = displaySize(size, rotation);
// % ของหน้า (บนจอ) → px บนจอ — ผ่าน utils/coordinates เท่านั้น (invariant #2)
const px = toPx;
const pxPoint = (p: Point): Point => ({
x: px(p.x, display.width),
y: px(p.y, display.height),
});
const pxRect = (r: Rect): Rect => ({
x: px(r.x, display.width),
y: px(r.y, display.height),
width: px(r.width, display.width),
height: px(r.height, display.height),
});
const toPdf = (p: Point) => displayToPdfPoint(p, size, rotation);
// strokeWidth เป็น % ของความกว้างหน้า **บนจอ**
const thickness = (swPct: number) => px(swPct, display.width);
// AnnotationDTO เป็น flat type (type/data แยกกัน) → cast ตาม case ที่เช็คแล้ว (pattern เดียวกับ sidebar)
switch (a.type) {
case "HIGHLIGHT": {
const d = a.data as HighlightData;
const { r, g, b } = hexToRgb01(d.color);
const col = rgb(r, g, b);
const st = d.style ?? "highlight";
// ความทึบของผู้ใช้ (0–1, ไม่มี = ทึบเต็ม) — ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35
// (สอดคล้อง mix-blend-multiply บนจอ), เส้นทุกแบบใช้ตรง ๆ
const op = d.opacity ?? 1;
for (const rPct of d.rects) {
const rect = displayRectToPdf(pxRect(rPct), size, rotation);
if (st === "underline") {
// เส้นใต้ที่ขอบล่างของบรรทัด (rect.y ของ PDF = ขอบล่างบนจอ)
page.drawLine({
start: { x: rect.x, y: rect.y },
end: { x: rect.x + rect.width, y: rect.y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "strikethrough") {
const y = rect.y + rect.height / 2;
page.drawLine({
start: { x: rect.x, y },
end: { x: rect.x + rect.width, y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "squiggle") {
// เส้นขยุกขยิก — sine เล็ก ๆ ระดับขอบล่าง วาดต่อเป็นท่อนสั้น ๆ
let prev = { x: rect.x, y: rect.y + 1.5 };
for (let t = 2; t <= rect.width; t += 2) {
const p = {
x: rect.x + t,
y: rect.y + 1.5 * Math.sin((t / 6) * Math.PI * 2),
};
page.drawLine({
start: prev,
end: p,
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
prev = p;
}
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: col,
opacity: 0.35 * op,
});
}
}
break;
}
case "DRAW": {
const d = a.data as DrawData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const t = Math.max(thickness(d.strokeWidth), 0.5);
// ความทึบตกลงมาจาก effectiveDrawOpacity (ค่าเดียวกับ render บนจอ) — โปร่งใส
// ใช้ lineCap butt กันปลาย segment ซ้อนกันเป็นจุดเข้มกว่าช่วงอื่น
const op = effectiveDrawOpacity(d);
const pts = d.paths.map((p) => toPdf(pxPoint(p)));
for (let i = 0; i < pts.length - 1; i++) {
page.drawLine({
start: pts[i],
end: pts[i + 1],
thickness: t,
color: rgb(r, g, b),
opacity: op,
lineCap: op < 1 ? LineCapStyle.Butt : LineCapStyle.Round,
});
}
break;
}
case "SHAPE": {
const d = a.data as ShapeData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const color = rgb(r, g, b);
const t = Math.max(thickness(d.strokeWidth), 0.5);
if ("rect" in d) {
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
if (d.shape === "ellipse") {
page.drawEllipse({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
xScale: Math.max(rect.width / 2, 0.1),
yScale: Math.max(rect.height / 2, 0.1),
borderColor: color,
borderWidth: t,
});
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: color,
borderWidth: t,
});
}
} else {
const p1 = toPdf(pxPoint(d.from));
const p2 = toPdf(pxPoint(d.to));
page.drawLine({
start: p1,
end: p2,
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
if (d.shape === "arrow") {
// หัวลูกศร = เส้นสั้น 2 เส้นกลับหลังจากปลาย to (มุม ±π/7 เหมือนบนจอ)
const head = Math.max(t * 3.5, 8);
const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
for (const spread of [Math.PI / 7, -Math.PI / 7]) {
page.drawLine({
start: p2,
end: {
x: p2.x - head * Math.cos(ang + spread),
y: p2.y - head * Math.sin(ang + spread),
},
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
}
}
}
break;
}
case "REDACTION": {
// ดำทึบทับจริง — ความปลอดภัยของ redaction อยู่ที่นี่ที่เดียว (D4)
const d = a.data as RedactionData;
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: rgb(0, 0, 0),
});
break;
}
case "COMMENT": {
const d = a.data as CommentData;
const pin = toPdf(pxPoint(d.position));
page.drawCircle({
x: pin.x,
y: pin.y,
size: 12,
color: rgb(254 / 255, 243 / 255, 199 / 255),
borderColor: rgb(252 / 255, 211 / 255, 77 / 255),
borderWidth: 1,
});
if (thaiFont) {
const font = thaiFont;
const fontSize = 9;
// FIX-5: ย่อความกว้างกล่องตามหน้าจริง — หน้าแคบกว่า 220pt
// เดิมทำให้ x ติดลบจนข้อความหลุดออกนอกขอบซ้ายเงียบ ๆ
const maxWidth = Math.min(220, size.width);
const lines = wrapTextByWidth(
`${a.userName}: ${d.message}`,
maxWidth,
(s) => font.widthOfTextAtSize(s, fontSize),
);
// FIX-5 (ต่อ): กันหลุดขอบบน — ข้อความวาด "ขึ้น" จากหมุด (แกน y ของ PDF ชี้ขึ้น)
// เมื่อหมุดใกล้ขอบบนหรือ wrap ยาวหลายบรรทัดจนบรรทัดบนสุดเลยขอบหน้า
// เลื่อนทั้งก้อนลงมาเท่าที่จำเป็น (ไม่ clamp รายบรรทัด ไม่งั้นบรรทัดซ้อนกัน)
const lineH = fontSize + 2;
const blockTop = pin.y + 4 + (lines.length - 1) * lineH;
const shift = Math.max(0, blockTop + fontSize - size.height);
lines.forEach((line, i) => {
page.drawText(line, {
x: Math.max(0, Math.min(pin.x + 10, size.width - maxWidth)),
y: pin.y + 4 + i * lineH - shift,
size: fontSize,
font,
color: rgb(0.2, 0.2, 0.2),
});
});
}
break;
}
case "TEXTBOX": {
// F17/B2 — วาดข้อความด้วย Sarabun (D5); ไม่มีฟอนต์ → กรอบแทน ต้องไม่พัง
const d = a.data as TextboxData;
const pos = toPdf(pxPoint(d.position));
const { r, g, b } = hexToRgb01(d.color);
if (thaiFont) {
const fontSize = Math.max(thickness(d.fontSize), 4);
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
const runs: StyledRun[] = d.segments?.length
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
: [{ text: d.text, bold: !!d.bold }];
const fontFor = (bold: boolean) => (bold && thaiBoldFont ? thaiBoldFont : thaiFont);
const widthOf = (s: string, bold: boolean) =>
fontFor(bold).widthOfTextAtSize(s, fontSize);
const lines = wrapStyledText(runs, maxWidth, widthOf);
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
const align = d.align ?? "left";
lines.forEach((lineRuns, i) => {
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
let x =
align === "center"
? pos.x + (maxWidth - lineW) / 2
: align === "right"
? pos.x + (maxWidth - lineW)
: pos.x;
for (const rn of lineRuns) {
page.drawText(rn.text, {
x,
// position เป็นมุมซ้ายบนบนจอ → เดินลงจากขอบบนทีละบรรทัด (พิกัด PDF แกน y ขึ้น)
y: pos.y - fontSize - i * (fontSize * 1.5),
size: fontSize,
font: fontFor(rn.bold),
color: rgb(r, g, b),
opacity: d.opacity ?? 1,
});
x += widthOf(rn.text, rn.bold);
}
});
} else {
const rect = displayRectToPdf(
pxRect({ x: d.position.x, y: d.position.y, width: 40, height: 10 }),
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: rgb(r, g, b),
borderWidth: 1,
});
}
break;
}
case "STAMP": {
// F18/B5, D7 — วาดกรอบ+ข้อความ; ไม่มีฟอนต์ → กรอบอย่างเดียว (D5 ต้องไม่พัง)
const d = a.data as StampData;
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
const stampRgb = rgb(r, g, b);
const w = px(d.scale, display.width);
const rect = displayRectToPdf(
{
x: px(d.position.x, display.width),
y: px(d.position.y, display.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
},
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
});
if (thaiFont) {
const font = thaiFont;
const fontSize = w * STAMP_FONT_RATIO;
const lines = wrapTextByWidth(
d.label,
w * 0.9,
(s) => font.widthOfTextAtSize(s, fontSize),
);
const lineH = fontSize * 1.2;
lines.forEach((line, i) => {
const textW = font.widthOfTextAtSize(line, fontSize);
page.drawText(line, {
x: rect.x + (rect.width - textW) / 2,
y: rect.y + rect.height - (rect.height - lines.length * lineH) / 2 - lineH * i - fontSize,
size: fontSize,
font,
color: stampRgb,
});
});
}
break;
}
}
}
const out = await doc.save();
const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
return new Response(new Uint8Array(out), {
headers: {
"content-type": "application/pdf",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย
"content-disposition": `attachment; filename="annotated.pdf"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -0,0 +1,64 @@
import { subscribeAnnotateEvents } from "@/lib/annotate-events";
// SSE endpoint (PLAN.md §4): client เปิด EventSource ไปที่นี่เพื่อรับ annotate event
// ของไฟล์เดียวกันแบบ realtime — event ถูก publish โดย Server Actions ผ่าน lib/annotate-events
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export async function GET(req: Request): Promise<Response> {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ต้องเป็น UUID", { status: 400 });
}
const encoder = new TextEncoder();
let cleanup: () => void = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: unknown) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
send({ type: "connected" });
const unsubscribe = subscribeAnnotateEvents(fileId, send);
// keep-alive กัน connection ถูกตัดเมื่อเงียบนาน (comment line — client ไม่เห็น)
const ping = setInterval(() => {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
// stream ปิดไปแล้ว — cleanup ทำหน้าที่ตัด interval
}
}, 30_000);
let done = false;
cleanup = () => {
if (done) return;
done = true;
clearInterval(ping);
unsubscribe();
try {
controller.close();
} catch {
// ปิดอยู่แล้ว
}
};
// client ปิดแท็บ/ตัดการเชื่อมต่อ → unsubscribe คืน memory (rules/realtime.md)
req.signal.addEventListener("abort", cleanup);
},
cancel() {
cleanup();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}

94
app/globals.css Normal file
View file

@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── TextLayer ของ pdfjs v4 ─────────────────────────────────────────────────
คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น)
ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
/* ห้ามใส่ z-index ที่นี่ — บล็อกนี้อยู่หลัง @tailwind utilities ใน cascade
จะ override z-[2] ของ Tailwind จน textLayer ไปอยู่ใต้ canvas (invariant #3) */
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
z-index: 1;
}
.textLayer span.markedContent {
top: 0;
height: 0;
}
.textLayer span[role="img"] {
user-select: none;
cursor: default;
}
.textLayer ::selection {
background: rgba(0 0 255 / 0.25);
background: color-mix(in srgb, AccentColor, transparent 75%);
}
.textLayer br::selection {
background: transparent;
}
.textLayer .endOfContent {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: 0;
cursor: default;
user-select: none;
}
.textLayer.selecting .endOfContent {
top: 0;
}
/* canvas วัด font metrics ที่ pdfjs.TextLayer สร้างแล้ว append ท้าย document.body
— คัดมาจาก pdf_viewer.css ของ pdfjs-dist เช่นกัน (ตอนแรกคัดมาแค่ส่วน .textLayer
กฎนี้จึงตกหล่น ทำให้ canvas ว่าง alpha:false แสดงเป็นก้อนดำ 300×150 ที่ล่างซ้าย
ของ document และเพิ่ม scrollHeight เกินจริง 150px) */
.hiddenCanvasElement {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
background-size: 100% 100%;
background-color: transparent;
}
/* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ─────
ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group
(รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link)
แต่ไม่โดนปุ่มใน dropdown panel (สี/ความหนา) ที่ซ้อนลึกกว่าอีกชั้น */
@media (pointer: coarse) {
header .touch-44 > button,
header .touch-44 > div > button,
header .touch-44 > div > div > button,
header .touch-44 > div > a {
min-height: 2.75rem;
min-width: 2.75rem;
}
}

20
app/layout.tsx Normal file
View file

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Jude's Note",
description: "ระบบ PDF Annotation สำหรับ e-Court",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="th">
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
</html>
);
}

52
app/page.tsx Normal file
View file

@ -0,0 +1,52 @@
import Link from "next/link";
import { asc } from "drizzle-orm";
import { FileText } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-2xl font-bold text-primary">Jude&apos;s Note</h1>
<p className="mt-1 text-sm text-slate-500">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{rows.length === 0 ? (
<p className="mt-8 rounded border border-dashed p-6 text-center text-sm text-slate-500">
ยังไม่มีไฟล์ในระบบ — รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
) : (
<ul className="mt-6 space-y-2">
{rows.map((f) => (
<li
key={f.id}
className="flex items-center gap-3 rounded-lg border bg-white p-4 shadow-sm"
>
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1">
<span className="block break-all line-clamp-2 text-sm font-medium">
{f.title}
</span>
<span className="block text-xs text-slate-400">
{new Date(f.createdAt).toLocaleString("th-TH")}
</span>
</span>
<Link
href={`/annotate/${f.id}`}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90"
>
Annotate
</Link>
</li>
))}
</ul>
)}
</main>
);
}