fix(unpair): allow whatsapp_groups delete by relaxing run_targets FK

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>
This commit is contained in:
yiekheng 2026-05-10 12:01:16 +08:00
parent 731d6d66a6
commit 52126765f4
5 changed files with 1064 additions and 14 deletions

View File

@ -40,9 +40,14 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
if (!session) {
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
for (const target of reminder.targets) {
const g = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, target.groupId),
columns: { name: true },
});
await db.insert(reminderRunTargets).values({
runId,
groupId: target.groupId,
groupLabel: g?.name ?? null,
status: "skipped",
error: "account not connected",
});
@ -64,6 +69,7 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
await db.insert(reminderRunTargets).values({
runId,
groupId: target.groupId,
groupLabel: null,
status: "skipped",
error: "group missing from db",
});
@ -104,6 +110,7 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
await db.insert(reminderRunTargets).values({
runId,
groupId: target.groupId,
groupLabel: group.name,
status: "sent",
waMessageId: lastMessageId ?? null,
latencyMs: Date.now() - start,
@ -114,6 +121,7 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
await db.insert(reminderRunTargets).values({
runId,
groupId: target.groupId,
groupLabel: group.name,
status: "failed",
error: (err as Error).message,
});

View File

@ -0,0 +1,11 @@
ALTER TABLE "reminder_run_targets" DROP CONSTRAINT "reminder_run_targets_group_id_whatsapp_groups_id_fk";
--> statement-breakpoint
ALTER TABLE "reminder_run_targets" DROP CONSTRAINT "reminder_run_targets_run_id_group_id_pk";--> statement-breakpoint
ALTER TABLE "reminder_run_targets" ALTER COLUMN "group_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "reminder_run_targets" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
ALTER TABLE "reminder_run_targets" ADD COLUMN "group_label" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "reminder_run_targets" ADD CONSTRAINT "reminder_run_targets_group_id_whatsapp_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."whatsapp_groups"("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

@ -43,6 +43,13 @@
"when": 1778347437350,
"tag": "0005_flippant_joystick",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1778385559051,
"tag": "0006_adorable_nehzno",
"breakpoints": true
}
]
}

View File

@ -122,20 +122,20 @@ export const reminderRuns = pgTable("reminder_runs", {
errorSummary: text("error_summary"),
});
export const reminderRunTargets = pgTable(
"reminder_run_targets",
{
runId: uuid("run_id").notNull().references(() => reminderRuns.id, { onDelete: "cascade" }),
groupId: uuid("group_id").notNull().references(() => whatsappGroups.id),
status: text("status").notNull(),
waMessageId: text("wa_message_id"),
error: text("error"),
latencyMs: integer("latency_ms"),
},
(t) => ({
pk: primaryKey({ columns: [t.runId, t.groupId] }),
}),
);
export const reminderRunTargets = pgTable("reminder_run_targets", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id").notNull().references(() => reminderRuns.id, { onDelete: "cascade" }),
// Nullable + ON DELETE SET NULL: unpair/delete-account wipes the
// associated whatsapp_groups rows but the historical fan-out record
// ("we tried to send to this group, here's the result") must survive.
// The accompanying snapshot field below preserves the readable label.
groupId: uuid("group_id").references(() => whatsappGroups.id, { onDelete: "set null" }),
groupLabel: text("group_label"),
status: text("status").notNull(),
waMessageId: text("wa_message_id"),
error: text("error"),
latencyMs: integer("latency_ms"),
});
export const auditLog = pgTable("audit_log", {
id: uuid("id").primaryKey().defaultRandom(),