fix(bot): group-sync soft-archives instead of DELETE — fixes FK abort

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 21:30:05 +08:00
parent 2fe8459d25
commit 08f2c0fd27
2 changed files with 37 additions and 16 deletions

View File

@ -7,35 +7,45 @@ import { logger } from "../logger.js";
export async function syncGroupsForAccount( export async function syncGroupsForAccount(
accountId: string, accountId: string,
socket: WASocket, socket: WASocket,
): Promise<{ synced: number; removed: number }> { ): Promise<{ synced: number; archived: number }> {
const meta = await socket.groupFetchAllParticipating(); const meta = await socket.groupFetchAllParticipating();
const entries = Object.values(meta); const entries = Object.values(meta);
const liveJids = entries.map((g) => g.id); const liveJids = entries.map((g) => g.id);
// Remove DB rows for groups that are no longer in the live participant list // Mark DB rows as archived when they're no longer in the live
// (group was deleted, bot was removed, etc.). Only run the delete when we // participant list (group deleted, bot removed, etc). We don't
// got at least one live group back — an empty result is more likely a // physically DELETE because reminder_targets.group_id is a NOT
// transient WA fetch failure than a genuine "all groups gone" signal, and // NULL FK to this row — a hard delete throws "violates foreign
// we don't want to nuke valid data on a hiccup. // key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
let removed: { id: string }[] = []; // 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) { if (liveJids.length > 0) {
removed = await db const rows = await db
.delete(whatsappGroups) .update(whatsappGroups)
.set({ isArchived: true, lastSyncedAt: new Date() })
.where( .where(
and( and(
eq(whatsappGroups.accountId, accountId), eq(whatsappGroups.accountId, accountId),
notInArray(whatsappGroups.waGroupJid, liveJids), notInArray(whatsappGroups.waGroupJid, liveJids),
eq(whatsappGroups.isArchived, false),
), ),
) )
.returning({ id: whatsappGroups.id }); .returning({ id: whatsappGroups.id });
archived = rows.length;
} }
if (entries.length === 0) { if (entries.length === 0) {
logger.info( logger.info(
{ accountId }, { 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) => ({ const rows = entries.map((g) => ({
@ -56,12 +66,16 @@ export async function syncGroupsForAccount(
name: sql`excluded.name`, name: sql`excluded.name`,
participantCount: sql`excluded.participant_count`, participantCount: sql`excluded.participant_count`,
lastSyncedAt: sql`excluded.last_synced_at`, 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( logger.info(
{ accountId, count: rows.length, removed: removed.length }, { accountId, count: rows.length, archived },
"group-sync: synced", "group-sync: synced",
); );
return { synced: rows.length, removed: removed.length }; return { synced: rows.length, archived };
} }

View File

@ -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(), id: uuid("id").primaryKey().defaultRandom(),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }), accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }),
waGroupJid: text("wa_group_jid").notNull(), waGroupJid: text("wa_group_jid").notNull(),