Symptom
-------
Click Unpair on a connected account (or Delete Account). The web
action runs:
DELETE FROM whatsapp_groups WHERE account_id = ?
and Postgres rejects it:
error: update or delete on table "whatsapp_groups" violates
foreign key constraint
"reminder_run_targets_group_id_whatsapp_groups_id_fk"
on table "reminder_run_targets"
Cause
-----
\`reminder_run_targets.group_id\` had a non-null FK to
whatsapp_groups.id with no ON DELETE rule (defaults to NO ACTION /
RESTRICT). So any reminder that had ever fired pinned the group rows
in place. Unpair couldn't wipe the synced groups, the action threw,
and the row never reached \`status='unpaired'\`.
Fix
---
Mirror the pattern \`reminder_runs.reminder_id\` already uses
(migration 0005): nullable column + ON DELETE SET NULL + a
denormalised label snapshot, so historical fan-out records survive a
group wipe but stay readable.
Migration 0006:
- Drop the composite \`(run_id, group_id)\` PK; add a surrogate
\`id uuid pk default gen_random_uuid()\` since \`group_id\` can no
longer be part of the PK once it's nullable.
- Make \`group_id\` nullable.
- Re-create the FK with ON DELETE SET NULL.
- Add \`group_label text\` for the snapshot.
fire-reminder.ts now writes the group's name into \`group_label\`
on every insert path (success / failed / skipped /
account-not-connected / group-missing) so the Activity tab can keep
showing "Sent to <Group Name>" even after the group is gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
Monthly / Yearly). Build the RRULE client-side and thread it through
the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
using the existing rrule helpers in @cmbot/shared. One-off behavior
unchanged.
- Add reminders.last_fired_at column to track last fire.
Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
silently truncating it; QR never reached the web → "QR hang"). PNG
now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
{type: session.qr, accountId, ts}. Web fetches the bytes from a new
read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
fresh one — fixes Re-pair where session.start was a silent no-op and
Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.
Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.
Account list page
- Cards are the link target. A small floating Delete trigger (top-right
trash icon) opens the destructive confirm dialog without blocking
navigation on the rest of the card.
Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
proves strict Zod .datetime() rejected luxon's offset ISO and the
{ offset: true } option accepts both forms.
Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A fired one-off reminder was staying active forever in the DB and showing
🟢 in the Reminders list. Update reminders.status to 'ended' once a one-off
has fired (regardless of run outcome — one-off is done after one attempt).
Recurring reminders stay 'active' — they have more occurrences pending.