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:
parent
2fe8459d25
commit
08f2c0fd27
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user