feat: dashboard navigation, preserve run history, QR refresh fix

Dashboard
- Stat cards are now clickable: Accounts → /accounts, Active reminders →
  /reminders?filter=active, Recent runs → /reminders.
- Recent activity rows link to the underlying reminder when it still
  exists. Runs whose reminder has been deleted render with a "(deleted)"
  marker and stay non-clickable.
- New "Clear history" action wipes all run rows the operator owns plus
  any orphan rows (reminderId=NULL).

Run history persists after reminder delete
- reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so
  deleting a reminder no longer cascade-erases its history.
- New reminder_runs.reminder_name column snapshots the name at fire
  time so history rows stay readable even after the reminder is gone.
- Fire-reminder records the snapshot.
- Dashboard query LEFT JOINs and COALESCEs name from the live reminder,
  the snapshot, or "(deleted reminder)" as last resort.

QR
- Drop the 25 s server-side throttle. With listener accumulation already
  fixed (previous commit), the payload-equality dedupe is enough.
  Symptom: after the first QR expired the throttle blocked the next
  emit, and the QR never refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 01:27:53 +08:00
parent f19ea03e0d
commit ba9e50fec0
9 changed files with 1246 additions and 68 deletions

View File

@ -14,13 +14,8 @@ import { pgNotifyWeb } from "./notify.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>(); const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>(); const lastQrPayload = new Map<string, string>();
const lastQrEmitMs = new Map<string, number>();
const pairTimeouts = new Map<string, NodeJS.Timeout>(); const pairTimeouts = new Map<string, NodeJS.Timeout>();
// Minimum spacing between QR refresh notifications. Prevents the UI from
// flashing through a new QR every few seconds when Baileys re-emits.
const QR_THROTTLE_MS = 25_000;
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId), where: (a, { eq }) => eq(a.id, accountId),
@ -74,7 +69,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
// Clear any stale QR lingering from a prior attempt. // Clear any stale QR lingering from a prior attempt.
lastQrPayload.delete(accountId); lastQrPayload.delete(accountId);
lastQrEmitMs.delete(accountId);
await db await db
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ lastQrPng: null }) .set({ lastQrPng: null })
@ -84,16 +78,11 @@ export async function handleStartPairing(accountId: string): Promise<void> {
if (id !== accountId) return; if (id !== accountId) return;
try { try {
if (event.type === "qr") { if (event.type === "qr") {
// Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one.
if (lastQrPayload.get(id) === event.payload) return; if (lastQrPayload.get(id) === event.payload) return;
const lastEmit = lastQrEmitMs.get(id) ?? 0;
const now = Date.now();
if (now - lastEmit < QR_THROTTLE_MS) {
// Baileys re-emits new QRs aggressively; surface no more than
// one every QR_THROTTLE_MS so the UI countdown doesn't flicker.
return;
}
lastQrPayload.set(id, event.payload); lastQrPayload.set(id, event.payload);
lastQrEmitMs.set(id, now);
const png = await renderQrPng(event.payload); const png = await renderQrPng(event.payload);
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit). // PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
// Persist on the account row; web fetches via /api/qr/[id]. // Persist on the account row; web fetches via /api/qr/[id].
@ -104,7 +93,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
await pgNotifyWeb({ await pgNotifyWeb({
type: "session.qr", type: "session.qr",
accountId: id, accountId: id,
ts: now, ts: Date.now(),
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
@ -113,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id); pairTimeouts.delete(id);
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
const session = sessionManager.getSession(id); const session = sessionManager.getSession(id);
let synced = 0; let synced = 0;
@ -147,7 +135,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id); pairTimeouts.delete(id);
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
await pgNotifyWeb({ type: "session.timeout", accountId: id }); await pgNotifyWeb({ type: "session.timeout", accountId: id });
off(); off();

View File

@ -26,7 +26,13 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
const [run] = await db const [run] = await db
.insert(reminderRuns) .insert(reminderRuns)
.values({ reminderId: reminder.id, status: "pending" }) .values({
reminderId: reminder.id,
// Snapshot the name so the run row stays readable in history even
// after the reminder is deleted (FK is ON DELETE SET NULL).
reminderName: reminder.name,
status: "pending",
})
.returning({ id: reminderRuns.id }); .returning({ id: reminderRuns.id });
const runId = run!.id; const runId = run!.id;

View File

@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { sql } from "drizzle-orm";
import { reminderRuns } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
if (r.limited) throw new Error("Too many requests");
}
/**
* Wipe the operator's reminder run history. Operators only see runs whose
* underlying reminder is still owned by them PLUS orphan runs (whose
* reminder was deleted) the dashboard query mirrors this. We delete
* both sets so "clear history" feels exhaustive.
*/
export async function clearHistoryAction(): Promise<void> {
await rateLimit("clear-history");
const op = await getSeededOperator();
await db.execute(sql`
DELETE FROM ${reminderRuns}
WHERE id IN (
SELECT rr.id
FROM ${reminderRuns} rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${op.id} OR r.id IS NULL
)
`);
revalidatePath("/");
revalidatePath("/reminders");
}

View File

@ -7,6 +7,7 @@ import {
AlertTriangleIcon, AlertTriangleIcon,
XCircleIcon, XCircleIcon,
MinusCircleIcon, MinusCircleIcon,
Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -16,6 +17,16 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clearHistoryAction } from "@/actions/history";
import { import {
Table, Table,
TableBody, TableBody,
@ -92,36 +103,44 @@ function RunStatusBadge({ status }: { status: string }) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stat card // Stat card — entire card is the link to its tab
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function StatCard({ function StatCard({
title, title,
value, value,
icon: Icon, icon: Icon,
description, description,
href,
}: { }: {
title: string; title: string;
value: string | number; value: string | number;
icon: React.ElementType; icon: React.ElementType;
description?: string; description?: string;
href: string;
}) { }) {
return ( return (
<Card> <Link
<CardHeader> // eslint-disable-next-line @typescript-eslint/no-explicit-any
<div className="flex items-center justify-between gap-2"> href={href as any}
<CardTitle className="text-sm font-medium text-muted-foreground"> className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
{title} >
</CardTitle> <Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<Icon className="size-4 text-muted-foreground shrink-0" /> <CardHeader>
</div> <div className="flex items-center justify-between gap-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">
<CardContent> {title}
<p className="text-2xl font-semibold tabular-nums">{value}</p> </CardTitle>
{description && ( <Icon className="size-4 text-muted-foreground shrink-0" />
<CardDescription className="mt-1 text-xs">{description}</CardDescription> </div>
)} </CardHeader>
</CardContent> <CardContent>
</Card> <p className="text-2xl font-semibold tabular-nums">{value}</p>
{description && (
<CardDescription className="mt-1 text-xs">{description}</CardDescription>
)}
</CardContent>
</Card>
</Link>
); );
} }
@ -138,52 +157,106 @@ export default async function DashboardPage() {
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8"> <div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
{/* Stat cards */} {/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard <StatCard
title="Accounts connected" title="Accounts connected"
value={`${stats.connectedAccounts} / ${stats.totalAccounts}`} value={`${stats.connectedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon} icon={WifiIcon}
description="WhatsApp accounts" description="WhatsApp accounts"
href="/accounts"
/> />
<StatCard <StatCard
title="Active reminders" title="Active reminders"
value={stats.activeReminders} value={stats.activeReminders}
icon={BellIcon} icon={BellIcon}
description="Scheduled & running" description="Scheduled & running"
href="/reminders?filter=active"
/> />
<StatCard <StatCard
title="Recent runs" title="Recent runs"
value={stats.recentRuns.length} value={stats.recentRuns.length}
icon={ActivityIcon} icon={ActivityIcon}
description="Last 10 reminder runs" description="Last 10 reminder runs"
href="/reminders"
/> />
</div> </div>
{/* Recent activity */} {/* Recent activity */}
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2> <div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
{hasRuns && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
{hasRuns ? ( {hasRuns ? (
<> <>
{/* Mobile: card list */} {/* Mobile: card list — clickable when the reminder still exists */}
<div className="flex flex-col gap-3 sm:hidden"> <div className="flex flex-col gap-3 sm:hidden">
{stats.recentRuns.map((run) => ( {stats.recentRuns.map((run) => {
<Card key={run.id} size="sm"> const body = (
<CardContent className="flex items-center justify-between gap-3 py-3"> <Card size="sm" className={run.is_deleted ? undefined : "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"}>
<div className="min-w-0"> <CardContent className="flex items-center justify-between gap-3 py-3">
<p className="text-sm font-medium truncate">{run.name}</p> <div className="min-w-0">
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-sm font-medium truncate">
{relativeTime(run.fired_at)} {run.name}
</p> {run.is_deleted && (
</div> <span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
<RunStatusBadge status={run.status} /> (deleted)
</CardContent> </span>
</Card> )}
))} </p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.fired_at)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
);
return run.reminder_id && !run.is_deleted ? (
<Link
key={run.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminder_id}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{body}
</Link>
) : (
<div key={run.id}>{body}</div>
);
})}
</div> </div>
{/* Desktop: table */} {/* Desktop: table — rows are clickable when reminder still exists */}
<div className="hidden sm:block"> <div className="hidden sm:block">
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
@ -196,17 +269,37 @@ export default async function DashboardPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{stats.recentRuns.map((run) => ( {stats.recentRuns.map((run) => {
<TableRow key={run.id}> const clickable = run.reminder_id && !run.is_deleted;
<TableCell className="font-medium">{run.name}</TableCell> return (
<TableCell> <TableRow
<RunStatusBadge status={run.status} /> key={run.id}
</TableCell> className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
<TableCell className="text-right text-muted-foreground text-xs"> >
{relativeTime(run.fired_at)} <TableCell className="font-medium">
</TableCell> {clickable ? (
</TableRow> <Link
))} // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminder_id}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.name}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.name}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
{relativeTime(run.fired_at)}
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>

View File

@ -9,12 +9,22 @@ export async function getDashboardStats(operatorId: string) {
const reminders = await db.query.reminders.findMany({ const reminders = await db.query.reminders.findMany({
where: (_, { sql: s }) => s`status = 'active'`, where: (_, { sql: s }) => s`status = 'active'`,
}); });
// LEFT JOIN so runs whose reminder has been deleted still appear. The
// ownership filter widens to: either the reminder still exists and the
// operator owns its account, OR the reminder is gone but the run row
// had a name snapshotted (history survives a delete by design).
const recentRuns = await db.execute(sql` const recentRuns = await db.execute(sql`
SELECT rr.id, rr.status, rr.fired_at, r.name SELECT
rr.id,
rr.status,
rr.fired_at,
rr.reminder_id,
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
r.id IS NULL AS is_deleted
FROM reminder_runs rr FROM reminder_runs rr
JOIN reminders r ON r.id = rr.reminder_id LEFT JOIN reminders r ON r.id = rr.reminder_id
JOIN whatsapp_accounts wa ON wa.id = r.account_id LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId} WHERE wa.operator_id = ${operatorId} OR r.id IS NULL
ORDER BY rr.fired_at DESC ORDER BY rr.fired_at DESC
LIMIT 10 LIMIT 10
`); `);
@ -26,7 +36,9 @@ export async function getDashboardStats(operatorId: string) {
id: string; id: string;
status: string; status: string;
fired_at: Date; fired_at: Date;
reminder_id: string | null;
name: string; name: string;
is_deleted: boolean;
}>, }>,
}; };
} }

View File

@ -0,0 +1,9 @@
ALTER TABLE "reminder_runs" DROP CONSTRAINT "reminder_runs_reminder_id_reminders_id_fk";
--> statement-breakpoint
ALTER TABLE "reminder_runs" ALTER COLUMN "reminder_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "reminder_runs" ADD COLUMN "reminder_name" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "reminder_runs" ADD CONSTRAINT "reminder_runs_reminder_id_reminders_id_fk" FOREIGN KEY ("reminder_id") REFERENCES "public"."reminders"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@
"when": 1778345543406, "when": 1778345543406,
"tag": "0004_next_prowler", "tag": "0004_next_prowler",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1778347437350,
"tag": "0005_flippant_joystick",
"breakpoints": true
} }
] ]
} }

View File

@ -112,7 +112,11 @@ export const reminderMessages = pgTable("reminder_messages", {
export const reminderRuns = pgTable("reminder_runs", { export const reminderRuns = pgTable("reminder_runs", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }), // Nullable + ON DELETE SET NULL: deleting a reminder must NOT erase its
// run history. The accompanying snapshot fields below preserve enough
// context to keep history rows readable.
reminderId: uuid("reminder_id").references(() => reminders.id, { onDelete: "set null" }),
reminderName: text("reminder_name"),
firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(), firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(),
status: text("status").notNull(), status: text("status").notNull(),
errorSummary: text("error_summary"), errorSummary: text("error_summary"),