diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts index 538eead..7d28135 100644 --- a/src/app/api/campaigns/[id]/route.ts +++ b/src/app/api/campaigns/[id]/route.ts @@ -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) } diff --git a/src/app/api/campaigns/[id]/schedule/route.ts b/src/app/api/campaigns/[id]/schedule/route.ts index 3523cbd..63dac82 100644 --- a/src/app/api/campaigns/[id]/schedule/route.ts +++ b/src/app/api/campaigns/[id]/schedule/route.ts @@ -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) } diff --git a/src/app/api/campaigns/[id]/send/route.ts b/src/app/api/campaigns/[id]/send/route.ts index 902961b..9030068 100644 --- a/src/app/api/campaigns/[id]/send/route.ts +++ b/src/app/api/campaigns/[id]/send/route.ts @@ -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({ - tenantId, - campaignId: params.id, - recipientEmail: recipient.email, - recipientHash: hashEmail(recipient.email), + // 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)) - return NextResponse.json({ queued: recipients.length }) + // 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: addedJobs.length }) } diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index 5ecc8a3..af67f68 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -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) diff --git a/src/lib/tenant-header.ts b/src/lib/tenant-header.ts new file mode 100644 index 0000000..6b5717b --- /dev/null +++ b/src/lib/tenant-header.ts @@ -0,0 +1,5 @@ +import { NextRequest } from 'next/server' + +export function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +}