Skip to content

[BUG] Failed broadcast recipient insertion leaves an orphaned sending broadcast #370

Description

@iuryeng

Summary

Broadcast creation persists the parent row before inserting recipients. If recipient insertion fails, the function throws but leaves the parent broadcast persisted with status sending and no recipients.

Environment

  • Version: 0.8.0
  • Commit: b8677608015790194c14ab7c85943ce26c7f9ef0
  • Reproduction: Vitest characterization with synthetic data
  • Repeated executions: 3

Steps to reproduce

  1. Call createBroadcast() with a valid synthetic broadcast and recipient list.
  2. Allow the parent broadcast insert to succeed.
  3. Make the recipient insert return a database error.
  4. Inspect the persisted parent state after the function throws.
npm test -- --run src/lib/whatsapp/broadcast-partial-repro.test.ts

No production credentials, phone numbers, customer content, or external services are used.

Expected behavior

Broadcast creation should be atomic. On recipient failure, no sending parent should remain, or the parent should move to an explicit failed state with a safe retry path.

Actual behavior

The function throws BroadcastError, but the parent remains:

Parent broadcast: created
Status:           sending
Recipient rows:   0
Function result:  BroadcastError

The result was identical in three consecutive focused test runs.

Technical evidence

The parent is inserted first:

.from('broadcasts')
.insert({
account_id: accountId,
user_id: auditUserId,
name: name || `API broadcast (${templateName})`,
template_name: templateName,
template_language: templateLanguage,
status: 'sending',
total_recipients: deduped.length,
})
.select('id')
.single();
if (bErr || !broadcast) {
console.error('[broadcast-core] create broadcast error:', bErr);
throw new BroadcastError('internal', 'Failed to create broadcast', 500);
}
const { data: recipientRows, error: rErr } = await db

Recipients are inserted in a separate operation, and failure throws without compensating for the parent:

.from('broadcast_recipients')
.insert(
deduped.map((r) => ({
broadcast_id: broadcast.id,
contact_id: r.contactId,
status: 'pending' as const,
}))
)
.select('id, contact_id');
if (rErr || !recipientRows) {
console.error('[broadcast-core] create recipients error:', rErr);
throw new BroadcastError('internal', 'Failed to create broadcast', 500);
}
// Pair each inserted recipient row back to its phone/params by
// contact_id — unambiguous now that duplicates are collapsed.
const byContact = new Map(deduped.map((r) => [r.contactId, r]));
const planned: PlannedRecipient[] = recipientRows.map((row) => {
const r = byContact.get(row.contact_id as string)!;
return { recipientRowId: row.id as string, phone: r.phone, params: r.params };
});

Impact

Transient database failures can leave campaigns that appear to be sending but have no delivery plan. Operators may retry and create additional orphaned campaigns, while operational views and counts contain misleading sending records.

Scope and limitations

This is a route-logic/unit-integration characterization with a Supabase chain mock. It demonstrates the missing compensation boundary in the code; it does not claim a live database outage or a specific production failure rate.

Suggested acceptance criteria

  • Parent and recipient rows are created atomically, preferably through a transaction/RPC.
  • A recipient insertion failure leaves no orphaned sending parent.
  • Retry behavior is explicit and does not create duplicate campaigns.
  • Regression coverage includes parent success followed by recipient failure.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions