From 2d466efd4f6fcfce8d0634657bdd56d169fc3f88 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Nov 2026 10:01:00 +0700 Subject: [PATCH] fix(note): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restoreVersion: ผ่าน validateAnnotationData ก่อน insert สาขา "ต้นทางถูกลบ" ให้สมมาตรกับสาขา update (invariant #8) - PDFViewer: track timer ล้าง exportError ใน ref — กัน export พัง สองรอบติดกันแล้ว timer รอบแรกเคลียร์ error รอบหลังก่อนเวลา - useHistory: เพิ่ม onError — undo/redo ล้มแสดง toast ผ่าน showActionError แทนหายเงียบ ๆ - DebugConsole: ไม่โหลด Eruda จาก CDN ใน production build (ระบบ intranet + token อยู่ใน origin เดียวกัน) - NoteHome: ตัด dead state session ที่ render ไม่ได้อ่าน - note-auth: export SSO_REQUIRED_BANNER ให้หน้า gate ใช้ตรง ๆ ถอด non-null assertion ใน AnnotateView + bookmarkDepthOf Verify: npx tsc --noEmit ผ่าน · vitest 317/317 ผ่าน Co-Authored-By: Claude Code --- app/actions/annotations.ts | 5 ++++- components/DebugConsole.tsx | 3 +++ components/NoteHome.tsx | 6 +----- components/pdf/AnnotateView.tsx | 4 ++-- components/pdf/PDFViewer.tsx | 26 ++++++++++++++++++++++---- hooks/useHistory.ts | 11 ++++++++++- lib/note-auth.ts | 12 ++++++++---- 7 files changed, 50 insertions(+), 17 deletions(-) diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts index 0a30fda..33375f0 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -1011,6 +1011,9 @@ export async function restoreVersion( if (v.userId !== uid) { throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น"); } + // validate สมมาตรกับสาขา "ต้นทางยังอยู่" — snapshot อาจเขียนสมัย validator หนาแน่น + // น้อยกว่าปัจจุบัน ห้ามไหลกลับเข้า annotations + SSE โดยไม่โดนตรวจ (invariant #8) + const validated = validateAnnotationData(v.type, v.data); const [row] = await tx .insert(annotations) .values({ @@ -1019,7 +1022,7 @@ export async function restoreVersion( userId: v.userId, userName: v.userName, type: v.type, - data: v.data, + data: validated, }) .returning(); return { action: "create" as const, row }; diff --git a/components/DebugConsole.tsx b/components/DebugConsole.tsx index 465e58d..96bbbe7 100644 --- a/components/DebugConsole.tsx +++ b/components/DebugConsole.tsx @@ -9,6 +9,9 @@ import { useEffect } from "react"; * app/note/annotate/[id]/page.tsx */ export default function DebugConsole() { useEffect(() => { + // production build ไม่โหลด CDN เด็ดขาด (ระบบ intranet + token อยู่ใน origin + // เดียวกับ script นี้) — ใช้ได้เฉพาะ dev server ที่เครื่องทดสอบเปิดให้ LAN + if (process.env.NODE_ENV === "production") return; if (!new URLSearchParams(window.location.search).has("debug")) return; // React strict mode รัน effect สองรอบ — กัน append script ซ้ำ if (document.getElementById("eruda-loader")) return; diff --git a/components/NoteHome.tsx b/components/NoteHome.tsx index 1906a3e..1845fab 100644 --- a/components/NoteHome.tsx +++ b/components/NoteHome.tsx @@ -20,7 +20,7 @@ import { import AppNav from "@/components/AppNav"; import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth"; -import { readNoteSession, saveNoteSession, type NoteSession } from "@/lib/note-session"; +import { saveNoteSession } from "@/lib/note-session"; import { withBasePath } from "@/utils/base-path"; type Phase = "boot" | "exchanging" | "ready"; @@ -41,7 +41,6 @@ export default function NoteHome() { const [phase, setPhase] = useState("boot"); const [banner, setBanner] = useState(null); const [fileError, setFileError] = useState(null); - const [session, setSession] = useState(null); // StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ const bootedRef = useRef(false); @@ -56,7 +55,6 @@ export default function NoteHome() { }; const fail = (reason: "expired" | "invalid" | "error") => { setBanner(ssoBanner(reason)); - setSession(readNoteSession()); setPhase("ready"); }; const parsed = parseSsoLinkParams(url); @@ -120,7 +118,6 @@ export default function NoteHome() { router.replace(`/note/annotate/${fileId}`); return; } - setSession(readNoteSession()); setPhase("ready"); } catch { stripParams(); @@ -145,7 +142,6 @@ export default function NoteHome() { setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined)); const openFileError = url.searchParams.get("fileError"); if (openFileError !== null && openFileError !== "") setFileError(openFileError); - setSession(readNoteSession()); setPhase("ready"); }, [router]); diff --git a/components/pdf/AnnotateView.tsx b/components/pdf/AnnotateView.tsx index 6be9b32..e58f8d1 100644 --- a/components/pdf/AnnotateView.tsx +++ b/components/pdf/AnnotateView.tsx @@ -10,7 +10,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) -import { ssoBanner } from "@/lib/note-auth"; +import { SSO_REQUIRED_BANNER } from "@/lib/note-auth"; import { clearNoteSession, noteAuthFetch, @@ -40,7 +40,7 @@ function GateScreen({ title }: { title: string }) { role="status" className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink" > - {ssoBanner("required")!.text} + {SSO_REQUIRED_BANNER.text}

{title}

diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 75c66cf..1ae6830 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -1335,6 +1335,7 @@ export default function PDFViewer({ // F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → ) ไม่เปิดแท็บใหม่ const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); + const exportErrorTimerRef = useRef(null); /** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch * ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic) * โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */ @@ -1448,7 +1449,14 @@ export default function PDFViewer({ redo: redoHistory, canUndo, canRedo, - } = useHistory(); + } = useHistory({ + onError: (_err, kind) => + showActionError( + kind === "undo" + ? "เรียกคืนไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง" + : "ทำซ้ำไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง", + ), + }); // Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition // React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว @@ -1690,7 +1698,13 @@ export default function PDFViewer({ } catch (err) { console.error("export PDF ไม่สำเร็จ", err); setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)"); - window.setTimeout(() => setExportError(null), 6000); + // track handle เหมือน actionErrorTimerRef — กัน export พังสองรอบติดกันแล้ว + // timer รอบแรกเคลียร์ error ของรอบหลังก่อนเวลา + if (exportErrorTimerRef.current !== null) window.clearTimeout(exportErrorTimerRef.current); + exportErrorTimerRef.current = window.setTimeout(() => { + exportErrorTimerRef.current = null; + setExportError(null); + }, 6000); } finally { setExporting(false); } @@ -2461,8 +2475,12 @@ export default function PDFViewer({ function bookmarkDepthOf(b: BookmarkDTO): number { let depth = 1; let cur: BookmarkDTO | undefined = b; - while (cur?.parentId && depth <= 100) { - const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId); + while (cur !== undefined && depth <= 100) { + // annotate ชัดกัน TS7022 — inference ของ cur วนอ้าง parent ที่อ้าง + // optimisticBookmarks ซึ่งยัง infer ไม่เสร็จ (function ถูกใช้ก่อนประกาศ) + const pid: string | null = cur.parentId; + if (!pid) break; + const parent: BookmarkDTO | undefined = optimisticBookmarks.find((x) => x.id === pid); if (!parent) break; depth += 1; cur = parent; diff --git a/hooks/useHistory.ts b/hooks/useHistory.ts index 64295d3..47eb09d 100644 --- a/hooks/useHistory.ts +++ b/hooks/useHistory.ts @@ -12,11 +12,18 @@ export type HistoryEntry = { redo: () => Promise; }; -export function useHistory() { +export function useHistory(options?: { + /** เรียกเมื่อ undo/redo ล้มเหลว (เน็ตสะดุด/session หมดอายุ ฯลฯ) — entry ถูกข้าม + * ไปแล้วโดย hook ผู้ใช้ควรเห็น toast ของ caller ไม่ใช่เงียบหายไปเฉย ๆ */ + onError?: (err: unknown, kind: "undo" | "redo") => void; +}) { // source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled const undoRef = useRef([]); const redoRef = useRef([]); const [, bump] = useState(0); + // เก็บ callback ล่าสุดของ caller ใน ref — callback ที่ส่งเข้ามาเปลี่ยนทุก render ได้ + const onErrorRef = useRef(options?.onError); + onErrorRef.current = options?.onError; /** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */ const push = useCallback((entry: HistoryEntry) => { @@ -34,6 +41,7 @@ export function useHistory() { } catch (err) { // เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5) console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err); + onErrorRef.current?.(err, "undo"); } bump((n) => n + 1); }, []); @@ -46,6 +54,7 @@ export function useHistory() { undoRef.current.push(entry); } catch (err) { console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err); + onErrorRef.current?.(err, "redo"); } bump((n) => n + 1); }, []); diff --git a/lib/note-auth.ts b/lib/note-auth.ts index f76765e..8dc6ce0 100644 --- a/lib/note-auth.ts +++ b/lib/note-auth.ts @@ -312,6 +312,13 @@ export function parseDevLinkBody(raw: unknown): DevLinkBody { export type SsoBanner = { intent: "warning" | "error"; text: string }; +/** ข้อความ gate คงที่ของหน้า annotate — คู่ขนาน `ssoBanner("required")` ให้หน้า + * gate ฝั่ง client ใช้ตรง ๆ โดยไม่ต้อง non-null assertion */ +export const SSO_REQUIRED_BANNER: SsoBanner = { + intent: "warning", + text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้", +}; + export function ssoBanner(reason: string | undefined): SsoBanner | null { switch (reason) { case "expired": @@ -330,10 +337,7 @@ export function ssoBanner(reason: string | undefined): SsoBanner | null { text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ", }; case "required": - return { - intent: "warning", - text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้", - }; + return SSO_REQUIRED_BANNER; default: return null; }