import { useEffect, useState } from "react"; import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema"; import type { AnnotateEvent } from "@/lib/annotate-events"; import { withBasePath } from "@/utils/base-path"; // SSE sync (PLAN.md §4, rules/realtime.md) // - เปิด EventSource ไป /api/annotations/stream?fileId= — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง) // - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง // - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4) export function useAnnotationRealtime( fileId: string, initialAnnotations: AnnotationDTO[], initialBookmarks: BookmarkDTO[], initialReplies: ReplyDTO[], ) { const [annotations, setAnnotations] = useState(initialAnnotations); const [bookmarks, setBookmarks] = useState(initialBookmarks); const [replies, setReplies] = useState(initialReplies); useEffect(() => { const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`)); es.onmessage = (e: MessageEvent) => { let ev: AnnotateEvent; try { ev = JSON.parse(e.data) as AnnotateEvent; } catch { return; } // กรอง message ที่ไม่ใช่ annotate event (เช่น { type: "connected" }) if (typeof ev.table !== "string" || typeof ev.action !== "string") return; if (ev.table === "annotations") { if (ev.action === "create") { setAnnotations((prev) => prev.some((a) => a.id === ev.row.id) ? prev : [...prev, ev.row], ); } else if (ev.action === "update") { // replace ตาม id — idempotent: event มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน (PLAN-FEAT.md §5) setAnnotations((prev) => prev.some((a) => a.id === ev.row.id) ? prev.map((a) => (a.id === ev.row.id ? ev.row : a)) : prev, ); } else { setAnnotations((prev) => prev.filter((a) => a.id !== ev.id)); // ลบแม่ → replies หายตาม CASCADE ของ DB — เก็บ state ให้เท่ากันด้วย (F9) setReplies((prev) => prev.filter((r) => r.annotationId !== ev.id)); } } else if (ev.table === "replies") { if (ev.action === "create") { setReplies((prev) => prev.some((r) => r.id === ev.row.id) ? prev : [...prev, ev.row], ); } else { setReplies((prev) => prev.filter((r) => r.id !== ev.id)); } } else { if (ev.action === "create") { setBookmarks((prev) => prev.some((b) => b.id === ev.row.id) ? prev : [...prev, ev.row], ); } else if (ev.action === "update") { // N2: แก้ชื่อ/โน้ต/ย้ายหัวข้อ (รวมลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — replace by id setBookmarks((prev) => prev.some((b) => b.id === ev.row.id) ? prev.map((b) => (b.id === ev.row.id ? ev.row : b)) : prev, ); } else { setBookmarks((prev) => prev.filter((b) => b.id !== ev.id)); } } }; return () => { es.close(); }; }, [fileId]); return { annotations, bookmarks, replies, setAnnotations, setBookmarks, setReplies }; }