diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index 45c5464..c8b84fa 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -14,13 +14,8 @@ import { pgNotifyWeb } from "./notify.js"; const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const offByAccount = new Map void>(); const lastQrPayload = new Map(); -const lastQrEmitMs = new Map(); const pairTimeouts = new Map(); -// 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 { } // 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 { 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 { 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 { 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 { pairTimeouts.delete(id); } lastQrPayload.delete(id); - lastQrEmitMs.delete(id); offByAccount.delete(id); await pgNotifyWeb({ type: "session.timeout", accountId: id }); off(); diff --git a/apps/bot/src/scheduler/fire-reminder.ts b/apps/bot/src/scheduler/fire-reminder.ts index b7e4db1..67bcca2 100644 --- a/apps/bot/src/scheduler/fire-reminder.ts +++ b/apps/bot/src/scheduler/fire-reminder.ts @@ -26,7 +26,13 @@ export async function fireReminder(payload: FireReminderPayload): Promise 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; diff --git a/apps/web/src/actions/history.ts b/apps/web/src/actions/history.ts new file mode 100644 index 0000000..3d9065e --- /dev/null +++ b/apps/web/src/actions/history.ts @@ -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 { + 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"); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 43b4799..6590fbf 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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 ( - - -
- - {title} - - -
-
- -

{value}

- {description && ( - {description} - )} -
-
+ + + +
+ + {title} + + +
+
+ +

{value}

+ {description && ( + {description} + )} +
+
+ ); } @@ -138,52 +157,106 @@ export default async function DashboardPage() {

Dashboard

- {/* Stat cards */} + {/* Stat cards — click to drill into the corresponding tab */}
{/* Recent activity */}
-

Recent activity

+
+

Recent activity

+ {hasRuns && ( + + + + + + + Clear all run history? + + This permanently removes every reminder run record, including + runs from reminders that have already been deleted. Reminders + themselves are not affected. + + + +
+ +
+
+
+
+ )} +
{hasRuns ? ( <> - {/* Mobile: card list */} + {/* Mobile: card list — clickable when the reminder still exists */}
- {stats.recentRuns.map((run) => ( - - -
-

{run.name}

-

- {relativeTime(run.fired_at)} -

-
- -
-
- ))} + {stats.recentRuns.map((run) => { + const body = ( + + +
+

+ {run.name} + {run.is_deleted && ( + + (deleted) + + )} +

+

+ {relativeTime(run.fired_at)} +

+
+ +
+
+ ); + return run.reminder_id && !run.is_deleted ? ( + + {body} + + ) : ( +
{body}
+ ); + })}
- {/* Desktop: table */} + {/* Desktop: table — rows are clickable when reminder still exists */}
@@ -196,17 +269,37 @@ export default async function DashboardPage() { - {stats.recentRuns.map((run) => ( - - {run.name} - - - - - {relativeTime(run.fired_at)} - - - ))} + {stats.recentRuns.map((run) => { + const clickable = run.reminder_id && !run.is_deleted; + return ( + + + {clickable ? ( + + {run.name} + + ) : ( + + {run.name} + + )} + + + + + + {relativeTime(run.fired_at)} + + + ); + })} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 25555ca..8f34bc3 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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; }>, }; } diff --git a/packages/db/migrations/0005_flippant_joystick.sql b/packages/db/migrations/0005_flippant_joystick.sql new file mode 100644 index 0000000..49a2b4c --- /dev/null +++ b/packages/db/migrations/0005_flippant_joystick.sql @@ -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 $$; diff --git a/packages/db/migrations/meta/0005_snapshot.json b/packages/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..cc04941 --- /dev/null +++ b/packages/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1019 @@ +{ + "id": "7ac909ad-0e99-4069-a47a-762b75d375de", + "prevId": "634bcce7-311b-4492-86ec-48afa1ee4d3a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_operator_id_operators_id_fk": { + "name": "audit_log_operator_id_operators_id_fk", + "tableFrom": "audit_log", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_operator_id_operators_id_fk": { + "name": "auth_sessions_operator_id_operators_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_hash_unique": { + "name": "auth_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache_entries": { + "name": "cache_entries", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_files": { + "name": "media_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename_original": { + "name": "filename_original", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_files_operator_id_operators_id_fk": { + "name": "media_files_operator_id_operators_id_fk", + "tableFrom": "media_files", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operators": { + "name": "operators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Kuala_Lumpur'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operators_telegram_user_id_uq": { + "name": "operators_telegram_user_id_uq", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_messages": { + "name": "reminder_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_messages_reminder_id_reminders_id_fk": { + "name": "reminder_messages_reminder_id_reminders_id_fk", + "tableFrom": "reminder_messages", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_messages_media_id_media_files_id_fk": { + "name": "reminder_messages_media_id_media_files_id_fk", + "tableFrom": "reminder_messages", + "tableTo": "media_files", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_run_targets": { + "name": "reminder_run_targets", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wa_message_id": { + "name": "wa_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_run_targets_run_id_reminder_runs_id_fk": { + "name": "reminder_run_targets_run_id_reminder_runs_id_fk", + "tableFrom": "reminder_run_targets", + "tableTo": "reminder_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_run_targets_group_id_whatsapp_groups_id_fk": { + "name": "reminder_run_targets_group_id_whatsapp_groups_id_fk", + "tableFrom": "reminder_run_targets", + "tableTo": "whatsapp_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reminder_run_targets_run_id_group_id_pk": { + "name": "reminder_run_targets_run_id_group_id_pk", + "columns": [ + "run_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_runs": { + "name": "reminder_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reminder_name": { + "name": "reminder_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_runs_reminder_id_reminders_id_fk": { + "name": "reminder_runs_reminder_id_reminders_id_fk", + "tableFrom": "reminder_runs", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_targets": { + "name": "reminder_targets", + "schema": "", + "columns": { + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_targets_reminder_id_reminders_id_fk": { + "name": "reminder_targets_reminder_id_reminders_id_fk", + "tableFrom": "reminder_targets", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_targets_group_id_whatsapp_groups_id_fk": { + "name": "reminder_targets_group_id_whatsapp_groups_id_fk", + "tableFrom": "reminder_targets", + "tableTo": "whatsapp_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reminder_targets_reminder_id_group_id_pk": { + "name": "reminder_targets_reminder_id_group_id_pk", + "columns": [ + "reminder_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminders": { + "name": "reminders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_kind": { + "name": "schedule_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminders_account_id_whatsapp_accounts_id_fk": { + "name": "reminders_account_id_whatsapp_accounts_id_fk", + "tableFrom": "reminders", + "tableTo": "whatsapp_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminders_created_by_operators_id_fk": { + "name": "reminders_created_by_operators_id_fk", + "tableFrom": "reminders", + "tableTo": "operators", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whatsapp_accounts": { + "name": "whatsapp_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_qr_at": { + "name": "last_qr_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_qr_png": { + "name": "last_qr_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "whatsapp_accounts_operator_label_uq": { + "name": "whatsapp_accounts_operator_label_uq", + "columns": [ + { + "expression": "operator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "whatsapp_accounts_operator_id_operators_id_fk": { + "name": "whatsapp_accounts_operator_id_operators_id_fk", + "tableFrom": "whatsapp_accounts", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whatsapp_groups": { + "name": "whatsapp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wa_group_jid": { + "name": "wa_group_jid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_count": { + "name": "participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "whatsapp_groups_account_jid_uq": { + "name": "whatsapp_groups_account_jid_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wa_group_jid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "whatsapp_groups_account_id_whatsapp_accounts_id_fk": { + "name": "whatsapp_groups_account_id_whatsapp_accounts_id_fk", + "tableFrom": "whatsapp_groups", + "tableTo": "whatsapp_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f69b568..bd0bf68 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778345543406, "tag": "0004_next_prowler", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1778347437350, + "tag": "0005_flippant_joystick", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3245d19..3932e83 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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"),