Files
coding-starter/src/app/api/campaigns/[id]/send/route.ts
Joachim Hummel e5db71ead1 fix: Bug 1+2+3 — SQL Cross-Join, PATCH 404 vs 400, API-Test-Skip
- getCampaignRecipients: EXISTS-Subquery statt Cross-Join verhindert Mehrfachversand
- updateCampaign: SELECT vor UPDATE unterscheidet 'nicht gefunden' (404) von 'nicht im Draft' (400)
- campaigns-api.test.ts: describe.skip entfernt, Mocks für DB-Abhängigkeiten ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:34:18 +00:00

70 lines
2.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getCampaign, updateCampaignStatus, getCampaignRecipients } from '../../../../../server/db/campaigns'
import { emailSendQueue } from '../../../../../queues/email-send.queue'
import { hashEmail } from '../../../../../lib/crypto'
import { checkSuppression } from '../../../../../server/suppression/check'
import { getTenantId } from '../../../../../lib/tenant-header'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const campaignResult = await getCampaign(tenantId, params.id)
if (!campaignResult.ok) {
return NextResponse.json({ error: campaignResult.error.message }, { status: 404 })
}
if (campaignResult.data.status !== 'draft') {
return NextResponse.json({ error: 'Nur Draft-Kampagnen können versendet werden' }, { status: 400 })
}
const statusResult = await updateCampaignStatus(tenantId, params.id, 'sending')
if (!statusResult.ok) {
return NextResponse.json({ error: statusResult.error.message }, { status: 500 })
}
const recipientsResult = await getCampaignRecipients(tenantId, params.id)
if (!recipientsResult.ok) {
// 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 })
}
const recipients = recipientsResult.data
// Suppression-Check PFLICHT — kein Opt-out-Empfänger darf in die Queue
const unsuppressedRecipients = await Promise.all(
recipients.map(async (r) => {
const suppressed = await checkSuppression(tenantId, r.email)
return suppressed ? null : r
})
).then((results) => results.filter((r): r is { email: string } => r !== null))
let addedJobs: { id?: string }[]
try {
addedJobs = await emailSendQueue.addBulk(
unsuppressedRecipients.map((recipient) => ({
name: 'send',
data: {
tenantId,
campaignId: params.id,
recipientEmail: recipient.email,
recipientHash: hashEmail(recipient.email),
},
opts: {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 2000 },
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 })
}