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:
parent
f19ea03e0d
commit
ba9e50fec0
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
41
apps/web/src/actions/history.ts
Normal file
41
apps/web/src/actions/history.ts
Normal 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");
|
||||
}
|
||||
@ -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,36 +103,44 @@ 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>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<Icon className="size-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
{description && (
|
||||
<CardDescription className="mt-1 text-xs">{description}</CardDescription>
|
||||
)}
|
||||
</CardContent>
|
||||
</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">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<Icon className="size-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<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">
|
||||
<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 ? (
|
||||
<>
|
||||
{/* 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">
|
||||
<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-xs text-muted-foreground mt-0.5">
|
||||
{relativeTime(run.fired_at)}
|
||||
</p>
|
||||
</div>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{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}
|
||||
{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>
|
||||
</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>
|
||||
|
||||
{/* Desktop: table */}
|
||||
{/* Desktop: table — rows are clickable when reminder still exists */}
|
||||
<div className="hidden sm:block">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
@ -196,17 +269,37 @@ export default async function DashboardPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats.recentRuns.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="font-medium">{run.name}</TableCell>
|
||||
<TableCell>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-xs">
|
||||
{relativeTime(run.fired_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{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>
|
||||
<TableCell className="text-right text-muted-foreground text-xs">
|
||||
{relativeTime(run.fired_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
@ -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;
|
||||
}>,
|
||||
};
|
||||
}
|
||||
|
||||
9
packages/db/migrations/0005_flippant_joystick.sql
Normal file
9
packages/db/migrations/0005_flippant_joystick.sql
Normal 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 $$;
|
||||
1019
packages/db/migrations/meta/0005_snapshot.json
Normal file
1019
packages/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,13 @@
|
||||
"when": 1778345543406,
|
||||
"tag": "0004_next_prowler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1778347437350,
|
||||
"tag": "0005_flippant_joystick",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user