From 08f2c0fd276eb8d45a84e8295b663ebc69f5c98f Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 21:30:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(bot):=20group-sync=20soft-archives=20instea?= =?UTF-8?q?d=20of=20DELETE=20=E2=80=94=20fixes=20FK=20abort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web error log showed: update or delete on table "whatsapp_groups" violates foreign key constraint "reminder_targets_group_id_whatsapp_groups_id_fk" on table "reminder_targets" Repro: pair finishes, post-open syncGroupsForAccount runs and tries to DELETE rows for groups no longer in the live participant list. If any of those groups had been used in a reminder its row is FK- referenced from reminder_targets, so the DELETE aborts the whole transaction and the operator's pair completion appears to fail. With 3 000+ groups per account this hits anyone with even a small reminder history. Switch the sweep from DELETE to UPDATE … SET is_archived=true. Reminders that targeted the missing group keep working (operator can choose to remove them); a future re-pair where the group reappears flips is_archived back to false via the on-conflict upsert. Returns archived count instead of removed count. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bot/src/whatsapp/group-sync.ts | 40 +++++++++++++++++++---------- packages/db/src/schema.ts | 13 +++++++--- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/bot/src/whatsapp/group-sync.ts b/apps/bot/src/whatsapp/group-sync.ts index d1d97ed..1171ffb 100644 --- a/apps/bot/src/whatsapp/group-sync.ts +++ b/apps/bot/src/whatsapp/group-sync.ts @@ -7,35 +7,45 @@ import { logger } from "../logger.js"; export async function syncGroupsForAccount( accountId: string, socket: WASocket, -): Promise<{ synced: number; removed: number }> { +): Promise<{ synced: number; archived: number }> { const meta = await socket.groupFetchAllParticipating(); const entries = Object.values(meta); const liveJids = entries.map((g) => g.id); - // Remove DB rows for groups that are no longer in the live participant list - // (group was deleted, bot was removed, etc.). Only run the delete when we - // got at least one live group back — an empty result is more likely a - // transient WA fetch failure than a genuine "all groups gone" signal, and - // we don't want to nuke valid data on a hiccup. - let removed: { id: string }[] = []; + // Mark DB rows as archived when they're no longer in the live + // participant list (group deleted, bot removed, etc). We don't + // physically DELETE because reminder_targets.group_id is a NOT + // NULL FK to this row — a hard delete throws "violates foreign + // key constraint reminder_targets_group_id_whatsapp_groups_id_fk" + // and aborts the WHOLE group-sync transaction (which then strands + // the post-pair open event and the operator sees it as a failed + // pairing). Soft-archive keeps reminders that targeted the group + // intact and gives the operator the option to clean them up + // explicitly later. Only run the sweep when we got at least one + // live group back — an empty result is usually a transient WA + // fetch failure and we don't want to mass-archive valid data. + let archived = 0; if (liveJids.length > 0) { - removed = await db - .delete(whatsappGroups) + const rows = await db + .update(whatsappGroups) + .set({ isArchived: true, lastSyncedAt: new Date() }) .where( and( eq(whatsappGroups.accountId, accountId), notInArray(whatsappGroups.waGroupJid, liveJids), + eq(whatsappGroups.isArchived, false), ), ) .returning({ id: whatsappGroups.id }); + archived = rows.length; } if (entries.length === 0) { logger.info( { accountId }, - "group-sync: empty fetch — skipping delete sweep (treating as transient)", + "group-sync: empty fetch — skipping archive sweep (treating as transient)", ); - return { synced: 0, removed: 0 }; + return { synced: 0, archived: 0 }; } const rows = entries.map((g) => ({ @@ -56,12 +66,16 @@ export async function syncGroupsForAccount( name: sql`excluded.name`, participantCount: sql`excluded.participant_count`, lastSyncedAt: sql`excluded.last_synced_at`, + // If a previously-archived group reappears in the live list + // (operator was re-added, group was un-deleted, etc.), flip + // the flag back so it shows up in the picker again. + isArchived: sql`excluded.is_archived`, }, }); logger.info( - { accountId, count: rows.length, removed: removed.length }, + { accountId, count: rows.length, archived }, "group-sync: synced", ); - return { synced: rows.length, removed: removed.length }; + return { synced: rows.length, archived }; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 23ef21d..1669a82 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -57,9 +57,16 @@ export const whatsappAccounts = pgTable( }), ); -export const whatsappGroups = pgTable( - "whatsapp_groups", - { +/** + * whatsapp_groups perf notes (production: 3 000+ rows per account): + * - account_jid_uq B-tree (account_id, wa_group_jid). + * Backs the on-conflict upsert during + * group-sync and every per-account + * WHERE-prefix scan. + * - whatsapp_groups_name_trgm GIN trgm index on `name` (migration + * 0002). Powers fuzzy search via the + * `name % term` operator in O(log n). + */ id: uuid("id").primaryKey().defaultRandom(), accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }), waGroupJid: text("wa_group_jid").notNull(),