fix(note): address code review findings
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
d37bc2a3f0
commit
2d466efd4f
7 changed files with 50 additions and 17 deletions
|
|
@ -1011,6 +1011,9 @@ export async function restoreVersion(
|
||||||
if (v.userId !== uid) {
|
if (v.userId !== uid) {
|
||||||
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
|
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
|
||||||
}
|
}
|
||||||
|
// validate สมมาตรกับสาขา "ต้นทางยังอยู่" — snapshot อาจเขียนสมัย validator หนาแน่น
|
||||||
|
// น้อยกว่าปัจจุบัน ห้ามไหลกลับเข้า annotations + SSE โดยไม่โดนตรวจ (invariant #8)
|
||||||
|
const validated = validateAnnotationData(v.type, v.data);
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.insert(annotations)
|
.insert(annotations)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -1019,7 +1022,7 @@ export async function restoreVersion(
|
||||||
userId: v.userId,
|
userId: v.userId,
|
||||||
userName: v.userName,
|
userName: v.userName,
|
||||||
type: v.type,
|
type: v.type,
|
||||||
data: v.data,
|
data: validated,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return { action: "create" as const, row };
|
return { action: "create" as const, row };
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { useEffect } from "react";
|
||||||
* app/note/annotate/[id]/page.tsx */
|
* app/note/annotate/[id]/page.tsx */
|
||||||
export default function DebugConsole() {
|
export default function DebugConsole() {
|
||||||
useEffect(() => {
|
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;
|
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
||||||
// React strict mode รัน effect สองรอบ — กัน append script ซ้ำ
|
// React strict mode รัน effect สองรอบ — กัน append script ซ้ำ
|
||||||
if (document.getElementById("eruda-loader")) return;
|
if (document.getElementById("eruda-loader")) return;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
|
|
||||||
import AppNav from "@/components/AppNav";
|
import AppNav from "@/components/AppNav";
|
||||||
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
|
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";
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
type Phase = "boot" | "exchanging" | "ready";
|
type Phase = "boot" | "exchanging" | "ready";
|
||||||
|
|
@ -41,7 +41,6 @@ export default function NoteHome() {
|
||||||
const [phase, setPhase] = useState<Phase>("boot");
|
const [phase, setPhase] = useState<Phase>("boot");
|
||||||
const [banner, setBanner] = useState<SsoBanner | null>(null);
|
const [banner, setBanner] = useState<SsoBanner | null>(null);
|
||||||
const [fileError, setFileError] = useState<string | null>(null);
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
const [session, setSession] = useState<NoteSession | null>(null);
|
|
||||||
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
|
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
|
||||||
const bootedRef = useRef(false);
|
const bootedRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -56,7 +55,6 @@ export default function NoteHome() {
|
||||||
};
|
};
|
||||||
const fail = (reason: "expired" | "invalid" | "error") => {
|
const fail = (reason: "expired" | "invalid" | "error") => {
|
||||||
setBanner(ssoBanner(reason));
|
setBanner(ssoBanner(reason));
|
||||||
setSession(readNoteSession());
|
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
};
|
};
|
||||||
const parsed = parseSsoLinkParams(url);
|
const parsed = parseSsoLinkParams(url);
|
||||||
|
|
@ -120,7 +118,6 @@ export default function NoteHome() {
|
||||||
router.replace(`/note/annotate/${fileId}`);
|
router.replace(`/note/annotate/${fileId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSession(readNoteSession());
|
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
} catch {
|
} catch {
|
||||||
stripParams();
|
stripParams();
|
||||||
|
|
@ -145,7 +142,6 @@ export default function NoteHome() {
|
||||||
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
|
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
|
||||||
const openFileError = url.searchParams.get("fileError");
|
const openFileError = url.searchParams.get("fileError");
|
||||||
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
|
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
|
||||||
setSession(readNoteSession());
|
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
||||||
import { ssoBanner } from "@/lib/note-auth";
|
import { SSO_REQUIRED_BANNER } from "@/lib/note-auth";
|
||||||
import {
|
import {
|
||||||
clearNoteSession,
|
clearNoteSession,
|
||||||
noteAuthFetch,
|
noteAuthFetch,
|
||||||
|
|
@ -40,7 +40,7 @@ function GateScreen({ title }: { title: string }) {
|
||||||
role="status"
|
role="status"
|
||||||
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
|
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
|
||||||
>
|
>
|
||||||
{ssoBanner("required")!.text}
|
{SSO_REQUIRED_BANNER.text}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-ink-soft">{title}</p>
|
<p className="mt-3 text-sm text-ink-soft">{title}</p>
|
||||||
<Link href="/note" className={`${BTN_CLS} mt-4`}>
|
<Link href="/note" className={`${BTN_CLS} mt-4`}>
|
||||||
|
|
|
||||||
|
|
@ -1335,6 +1335,7 @@ export default function PDFViewer({
|
||||||
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
const exportErrorTimerRef = useRef<number | null>(null);
|
||||||
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
|
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
|
||||||
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
|
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
|
||||||
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
|
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
|
||||||
|
|
@ -1448,7 +1449,14 @@ export default function PDFViewer({
|
||||||
redo: redoHistory,
|
redo: redoHistory,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
} = useHistory();
|
} = useHistory({
|
||||||
|
onError: (_err, kind) =>
|
||||||
|
showActionError(
|
||||||
|
kind === "undo"
|
||||||
|
? "เรียกคืนไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง"
|
||||||
|
: "ทำซ้ำไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
|
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
|
||||||
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
|
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
|
||||||
|
|
@ -1690,7 +1698,13 @@ export default function PDFViewer({
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("export PDF ไม่สำเร็จ", err);
|
console.error("export PDF ไม่สำเร็จ", err);
|
||||||
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
|
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 {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -2461,8 +2475,12 @@ export default function PDFViewer({
|
||||||
function bookmarkDepthOf(b: BookmarkDTO): number {
|
function bookmarkDepthOf(b: BookmarkDTO): number {
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
let cur: BookmarkDTO | undefined = b;
|
let cur: BookmarkDTO | undefined = b;
|
||||||
while (cur?.parentId && depth <= 100) {
|
while (cur !== undefined && depth <= 100) {
|
||||||
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId);
|
// 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;
|
if (!parent) break;
|
||||||
depth += 1;
|
depth += 1;
|
||||||
cur = parent;
|
cur = parent;
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,18 @@ export type HistoryEntry = {
|
||||||
redo: () => Promise<void>;
|
redo: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
// source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled
|
||||||
const undoRef = useRef<HistoryEntry[]>([]);
|
const undoRef = useRef<HistoryEntry[]>([]);
|
||||||
const redoRef = useRef<HistoryEntry[]>([]);
|
const redoRef = useRef<HistoryEntry[]>([]);
|
||||||
const [, bump] = useState(0);
|
const [, bump] = useState(0);
|
||||||
|
// เก็บ callback ล่าสุดของ caller ใน ref — callback ที่ส่งเข้ามาเปลี่ยนทุก render ได้
|
||||||
|
const onErrorRef = useRef(options?.onError);
|
||||||
|
onErrorRef.current = options?.onError;
|
||||||
|
|
||||||
/** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */
|
/** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */
|
||||||
const push = useCallback((entry: HistoryEntry) => {
|
const push = useCallback((entry: HistoryEntry) => {
|
||||||
|
|
@ -34,6 +41,7 @@ export function useHistory() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5)
|
// เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5)
|
||||||
console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
||||||
|
onErrorRef.current?.(err, "undo");
|
||||||
}
|
}
|
||||||
bump((n) => n + 1);
|
bump((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -46,6 +54,7 @@ export function useHistory() {
|
||||||
undoRef.current.push(entry);
|
undoRef.current.push(entry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
||||||
|
onErrorRef.current?.(err, "redo");
|
||||||
}
|
}
|
||||||
bump((n) => n + 1);
|
bump((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,13 @@ export function parseDevLinkBody(raw: unknown): DevLinkBody {
|
||||||
|
|
||||||
export type SsoBanner = { intent: "warning" | "error"; text: string };
|
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 {
|
export function ssoBanner(reason: string | undefined): SsoBanner | null {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "expired":
|
case "expired":
|
||||||
|
|
@ -330,10 +337,7 @@ export function ssoBanner(reason: string | undefined): SsoBanner | null {
|
||||||
text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ",
|
text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ",
|
||||||
};
|
};
|
||||||
case "required":
|
case "required":
|
||||||
return {
|
return SSO_REQUIRED_BANNER;
|
||||||
intent: "warning",
|
|
||||||
text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue