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:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
951
app/actions/annotations.ts
Normal file
951
app/actions/annotations.ts
Normal 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);
|
||||
}
|
||||
63
app/annotate/[id]/page.tsx
Normal file
63
app/annotate/[id]/page.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
118
app/annotate/[id]/summary/page.tsx
Normal file
118
app/annotate/[id]/summary/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/api/annotations/export-csv/route.ts
Normal file
55
app/api/annotations/export-csv/route.ts
Normal 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
450
app/api/annotations/export/route.ts
Normal file
450
app/api/annotations/export/route.ts
Normal 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
64
app/api/annotations/stream/route.ts
Normal file
64
app/api/annotations/stream/route.ts
Normal 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
94
app/globals.css
Normal 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
20
app/layout.tsx
Normal 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
52
app/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue