fix: API Routes — Ergebnisprüfung, Suppression-Check vor Enqueue, Bulk-Enqueue, Status-Codes, Draft-Guard

- getTenantId in shared Utility src/lib/tenant-header.ts ausgelagert (alle 4 Route-Dateien)
- send/route.ts: updateCampaignStatus-Ergebnis geprüft (C1), addBulk statt sequentiellem await (C2), Suppression-Check vor Enqueue (C3)
- [id]/route.ts: GET unterscheidet 404/500, PATCH unterscheidet 404/400
- schedule/route.ts: immediate-Typ abgelehnt (I4), Draft-Guard hinzugefügt (I5)
- .eslintrc.json ergänzt (next/core-web-vitals) — fehlte im Projekt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 13:44:42 +00:00
parent e2f2ce6a56
commit 889bfad085
6 changed files with 73 additions and 39 deletions

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -1,14 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { UpdateCampaignSchema } from '../../../../lib/validation'
import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
import { getTenantId } from '../../../../lib/tenant-header'
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const result = await getCampaign(getTenantId(req), params.id)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 404 })
if (!result.ok) {
const status = result.error.message.includes('nicht gefunden') ? 404 : 500
return NextResponse.json({ error: result.error.message }, { status })
}
return NextResponse.json(result.data)
}
@@ -18,6 +18,9 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
const parsed = UpdateCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const result = await updateCampaign(tenantId, params.id, parsed.data)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 })
if (!result.ok) {
const status = result.error.message.includes('nicht gefunden') ? 404 : 400
return NextResponse.json({ error: result.error.message }, { status })
}
return NextResponse.json(result.data)
}

View File

@@ -1,10 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { ScheduleCampaignSchema } from '../../../../../lib/validation'
import { updateCampaignStatus } from '../../../../../server/db/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns'
import { getTenantId } from '../../../../../lib/tenant-header'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
@@ -12,17 +9,26 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
const parsed = ScheduleCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
// immediate-Typ gehört zur /send Route, nicht zu /schedule (Fix I4)
if (parsed.data.type === 'immediate') {
return NextResponse.json({ error: 'Für sofortigen Versand /send verwenden' }, { status: 400 })
}
// Draft-Status-Guard — nur Draft-Kampagnen können geplant werden (Fix I5)
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 geplant werden' }, { status: 400 })
}
const schedule = parsed.data
let scheduledAt: Date | undefined
let cronExpression: string | undefined
const scheduledAt = schedule.type === 'once' ? schedule.scheduledAt : undefined
const cronExpression = schedule.type === 'cron' ? schedule.cronExpression : undefined
if (schedule.type === 'once') scheduledAt = schedule.scheduledAt
if (schedule.type === 'cron') cronExpression = schedule.cronExpression
const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', {
scheduledAt,
cronExpression,
})
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 })
const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', { scheduledAt, cronExpression })
if (!result.ok) {
const status = result.error.message.includes('nicht gefunden') ? 404 : 400
return NextResponse.json({ error: result.error.message }, { status })
}
return NextResponse.json(result.data)
}

View File

@@ -1,12 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns'
import { enqueueEmailSend } from '../../../../../queues/email-send.queue'
import { emailSendQueue } from '../../../../../queues/email-send.queue'
import { withTenant } from '../../../../../server/db/tenant'
import { hashEmail } from '../../../../../lib/crypto'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
import { checkSuppression } from '../../../../../server/suppression/check'
import { getTenantId } from '../../../../../lib/tenant-header'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
@@ -19,7 +17,11 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
return NextResponse.json({ error: 'Nur Draft-Kampagnen können versendet werden' }, { status: 400 })
}
await updateCampaignStatus(tenantId, params.id, 'sending')
// Status auf 'sending' setzen — Ergebnis prüfen (Fix C1)
const statusResult = await updateCampaignStatus(tenantId, params.id, 'sending')
if (!statusResult.ok) {
return NextResponse.json({ error: statusResult.error.message }, { status: 500 })
}
const recipients = await withTenant(tenantId, (client) =>
client.query<{ email: string }>(
@@ -31,14 +33,32 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
)
)
for (const recipient of recipients) {
await enqueueEmailSend({
// Suppression-Check PFLICHT — kein Opt-out-Empfänger darf in die Queue (Fix C3)
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))
// Bulk-Enqueue — schnell, kein sequenzielles await (Fix C2)
const 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 },
},
}))
)
return NextResponse.json({ queued: recipients.length })
return NextResponse.json({ queued: addedJobs.length })
}

View File

@@ -1,10 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { CreateCampaignSchema } from '../../../lib/validation'
import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
import { getTenantId } from '../../../lib/tenant-header'
export async function GET(req: NextRequest) {
const tenantId = getTenantId(req)

5
src/lib/tenant-header.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextRequest } from 'next/server'
export function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}