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 offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>();
const lastQrEmitMs = new Map<string, number>();
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 }> {
const account = await db.query.whatsappAccounts.findFirst({
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.
lastQrPayload.delete(accountId);
lastQrEmitMs.delete(accountId);
await db
.update(whatsappAccounts)
.set({ lastQrPng: null })
@ -84,16 +78,11 @@ export async function handleStartPairing(accountId: string): Promise<void> {
if (id !== accountId) return;
try {
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;
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);
lastQrEmitMs.set(id, now);
const png = await renderQrPng(event.payload);
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
// 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({
type: "session.qr",
accountId: id,
ts: now,
ts: Date.now(),
});
} else if (event.type === "open") {
const t = pairTimeouts.get(id);
@ -113,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id);
const session = sessionManager.getSession(id);
let synced = 0;
@ -147,7 +135,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id);
await pgNotifyWeb({ type: "session.timeout", accountId: id });
off();

View File

@ -26,7 +26,13 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
const [run] = await db
.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 });
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,
XCircleIcon,
MinusCircleIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -16,6 +17,16 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clearHistoryAction } from "@/actions/history";
import {
Table,
TableBody,
@ -92,21 +103,28 @@ function RunStatusBadge({ status }: { status: string }) {
}
// ---------------------------------------------------------------------------
// Stat card
// Stat card — entire card is the link to its tab
// ---------------------------------------------------------------------------
function StatCard({
title,
value,
icon: Icon,
description,
href,
}: {
title: string;
value: string | number;
icon: React.ElementType;
description?: string;
href: string;
}) {
return (
<Card>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
@ -122,6 +140,7 @@ function StatCard({
)}
</CardContent>
</Card>
</Link>
);
}
@ -138,41 +157,82 @@ 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">
<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">
<StatCard
title="Accounts connected"
value={`${stats.connectedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon}
description="WhatsApp accounts"
href="/accounts"
/>
<StatCard
title="Active reminders"
value={stats.activeReminders}
icon={BellIcon}
description="Scheduled & running"
href="/reminders?filter=active"
/>
<StatCard
title="Recent runs"
value={stats.recentRuns.length}
icon={ActivityIcon}
description="Last 10 reminder runs"
href="/reminders"
/>
</div>
{/* Recent activity */}
<section className="space-y-4">
<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 ? (
<>
{/* Mobile: card list */}
{/* Mobile: card list — clickable when the reminder still exists */}
<div className="flex flex-col gap-3 sm:hidden">
{stats.recentRuns.map((run) => (
<Card key={run.id} size="sm">
{stats.recentRuns.map((run) => {
const body = (
<Card size="sm" className={run.is_deleted ? undefined : "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"}>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{run.name}</p>
<p className="text-sm font-medium truncate">
{run.name}
{run.is_deleted && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
(deleted)
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.fired_at)}
</p>
@ -180,10 +240,23 @@ export default async function DashboardPage() {
<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>
{/* Desktop: table */}
{/* Desktop: table — rows are clickable when reminder still exists */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
@ -196,9 +269,28 @@ export default async function DashboardPage() {
</TableRow>
</TableHeader>
<TableBody>
{stats.recentRuns.map((run) => (
<TableRow key={run.id}>
<TableCell className="font-medium">{run.name}</TableCell>
{stats.recentRuns.map((run) => {
const clickable = run.reminder_id && !run.is_deleted;
return (
<TableRow
key={run.id}
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
<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>
@ -206,7 +298,8 @@ export default async function DashboardPage() {
{relativeTime(run.fired_at)}
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</CardContent>

View File

@ -9,12 +9,22 @@ export async function getDashboardStats(operatorId: string) {
const reminders = await db.query.reminders.findMany({
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`
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
JOIN reminders r ON r.id = rr.reminder_id
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
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 = ${operatorId} OR r.id IS NULL
ORDER BY rr.fired_at DESC
LIMIT 10
`);
@ -26,7 +36,9 @@ export async function getDashboardStats(operatorId: string) {
id: string;
status: string;
fired_at: Date;
reminder_id: string | null;
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,
"tag": "0004_next_prowler",
"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", {
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(),
status: text("status").notNull(),
errorSummary: text("error_summary"),