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 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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
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,
|
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,21 +103,28 @@ 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
|
||||||
|
// 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>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
@ -122,6 +140,7 @@ function StatCard({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
|
<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 = (
|
||||||
|
<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">
|
<CardContent className="flex items-center justify-between gap-3 py-3">
|
||||||
<div className="min-w-0">
|
<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">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{relativeTime(run.fired_at)}
|
{relativeTime(run.fired_at)}
|
||||||
</p>
|
</p>
|
||||||
@ -180,10 +240,23 @@ export default async function DashboardPage() {
|
|||||||
<RunStatusBadge status={run.status} />
|
<RunStatusBadge status={run.status} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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,9 +269,28 @@ 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 (
|
||||||
|
<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>
|
<TableCell>
|
||||||
<RunStatusBadge status={run.status} />
|
<RunStatusBadge status={run.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -206,7 +298,8 @@ export default async function DashboardPage() {
|
|||||||
{relativeTime(run.fired_at)}
|
{relativeTime(run.fired_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -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;
|
||||||
}>,
|
}>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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"),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user