fix: Rollback auf draft bei Versand-Fehlern, Fehlermeldung updateCampaign

- send/route.ts: try/catch + Rollback auf draft wenn Empfänger-Query oder Queue-Enqueue fehlschlägt
- campaigns.ts: Fehlermeldung 'Kampagne nicht im Draft-Status' (zuvor enthielt sie 'nicht gefunden' — verursachte falschen 404 in PATCH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 13:50:21 +00:00
parent 889bfad085
commit 25889a5419
2 changed files with 41 additions and 29 deletions

View File

@@ -17,23 +17,29 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
return NextResponse.json({ error: 'Nur Draft-Kampagnen können versendet werden' }, { status: 400 }) return NextResponse.json({ error: 'Nur Draft-Kampagnen können versendet werden' }, { status: 400 })
} }
// Status auf 'sending' setzen — Ergebnis prüfen (Fix C1)
const statusResult = await updateCampaignStatus(tenantId, params.id, 'sending') const statusResult = await updateCampaignStatus(tenantId, params.id, 'sending')
if (!statusResult.ok) { if (!statusResult.ok) {
return NextResponse.json({ error: statusResult.error.message }, { status: 500 }) return NextResponse.json({ error: statusResult.error.message }, { status: 500 })
} }
const recipients = await withTenant(tenantId, (client) => let recipients: { email: string }[]
client.query<{ email: string }>( try {
`SELECT s.email FROM subscribers s recipients = await withTenant(tenantId, (client) =>
JOIN campaign_recipients cr ON cr.campaign_id = $1 client.query<{ email: string }>(
WHERE (cr.list_id IS NULL OR s.list_id = cr.list_id) `SELECT s.email FROM subscribers s
AND s.status = 'active'`, JOIN campaign_recipients cr ON cr.campaign_id = $1
[params.id] WHERE (cr.list_id IS NULL OR s.list_id = cr.list_id)
AND s.status = 'active'`,
[params.id]
)
) )
) } catch (e) {
// Rollback auf draft — verhindert verwaisten 'sending'-Status
await updateCampaignStatus(tenantId, params.id, 'draft')
return NextResponse.json({ error: 'Empfänger konnten nicht geladen werden' }, { status: 500 })
}
// Suppression-Check PFLICHT — kein Opt-out-Empfänger darf in die Queue (Fix C3) // Suppression-Check PFLICHT — kein Opt-out-Empfänger darf in die Queue
const unsuppressedRecipients = await Promise.all( const unsuppressedRecipients = await Promise.all(
recipients.map(async (r) => { recipients.map(async (r) => {
const suppressed = await checkSuppression(tenantId, r.email) const suppressed = await checkSuppression(tenantId, r.email)
@@ -41,24 +47,30 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
}) })
).then((results) => results.filter((r): r is { email: string } => r !== null)) ).then((results) => results.filter((r): r is { email: string } => r !== null))
// Bulk-Enqueue — schnell, kein sequenzielles await (Fix C2) let addedJobs: { id?: string }[]
const addedJobs = await emailSendQueue.addBulk( try {
unsuppressedRecipients.map((recipient) => ({ addedJobs = await emailSendQueue.addBulk(
name: 'send', unsuppressedRecipients.map((recipient) => ({
data: { name: 'send',
tenantId, data: {
campaignId: params.id, tenantId,
recipientEmail: recipient.email, campaignId: params.id,
recipientHash: hashEmail(recipient.email), recipientEmail: recipient.email,
}, recipientHash: hashEmail(recipient.email),
opts: { },
attempts: 3, opts: {
backoff: { type: 'exponential' as const, delay: 2000 }, attempts: 3,
removeOnComplete: 100, backoff: { type: 'exponential' as const, delay: 2000 },
removeOnFail: { count: 500 }, removeOnComplete: 100,
}, removeOnFail: { count: 500 },
})) },
) }))
)
} catch (e) {
// Rollback auf draft — Queue-Fehler darf nicht zu verwaisten Kampagnen führen
await updateCampaignStatus(tenantId, params.id, 'draft')
return NextResponse.json({ error: 'Jobs konnten nicht eingestellt werden' }, { status: 500 })
}
return NextResponse.json({ queued: addedJobs.length }) return NextResponse.json({ queued: addedJobs.length })
} }

View File

@@ -86,7 +86,7 @@ export async function updateCampaign(
values values
) )
) )
if (rows.length === 0) return err(new Error('Kampagne nicht gefunden oder nicht im Draft-Status')) if (rows.length === 0) return err(new Error('Kampagne nicht im Draft-Status'))
return ok(rowToCampaign(rows[0])) return ok(rowToCampaign(rows[0]))
} catch (e) { } catch (e) {
return err(e instanceof Error ? e : new Error(String(e))) return err(e instanceof Error ? e : new Error(String(e)))