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:
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { UpdateCampaignSchema } from '../../../../lib/validation'
|
import { UpdateCampaignSchema } from '../../../../lib/validation'
|
||||||
import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
|
import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
|
||||||
|
import { getTenantId } from '../../../../lib/tenant-header'
|
||||||
function getTenantId(req: NextRequest): string {
|
|
||||||
return req.headers.get('x-tenant-id') ?? 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
const result = await getCampaign(getTenantId(req), params.id)
|
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)
|
return NextResponse.json(result.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
const parsed = UpdateCampaignSchema.safeParse(body)
|
const parsed = UpdateCampaignSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
||||||
const result = await updateCampaign(tenantId, params.id, parsed.data)
|
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)
|
return NextResponse.json(result.data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { ScheduleCampaignSchema } from '../../../../../lib/validation'
|
import { ScheduleCampaignSchema } from '../../../../../lib/validation'
|
||||||
import { updateCampaignStatus } from '../../../../../server/db/campaigns'
|
import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns'
|
||||||
|
import { getTenantId } from '../../../../../lib/tenant-header'
|
||||||
function getTenantId(req: NextRequest): string {
|
|
||||||
return req.headers.get('x-tenant-id') ?? 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
const tenantId = getTenantId(req)
|
const tenantId = getTenantId(req)
|
||||||
@@ -12,17 +9,26 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
const parsed = ScheduleCampaignSchema.safeParse(body)
|
const parsed = ScheduleCampaignSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
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
|
const schedule = parsed.data
|
||||||
let scheduledAt: Date | undefined
|
const scheduledAt = schedule.type === 'once' ? schedule.scheduledAt : undefined
|
||||||
let cronExpression: string | undefined
|
const cronExpression = schedule.type === 'cron' ? schedule.cronExpression : undefined
|
||||||
|
|
||||||
if (schedule.type === 'once') scheduledAt = schedule.scheduledAt
|
const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', { scheduledAt, cronExpression })
|
||||||
if (schedule.type === 'cron') cronExpression = schedule.cronExpression
|
if (!result.ok) {
|
||||||
|
const status = result.error.message.includes('nicht gefunden') ? 404 : 400
|
||||||
const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', {
|
return NextResponse.json({ error: result.error.message }, { status })
|
||||||
scheduledAt,
|
}
|
||||||
cronExpression,
|
|
||||||
})
|
|
||||||
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 })
|
|
||||||
return NextResponse.json(result.data)
|
return NextResponse.json(result.data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns'
|
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 { withTenant } from '../../../../../server/db/tenant'
|
||||||
import { hashEmail } from '../../../../../lib/crypto'
|
import { hashEmail } from '../../../../../lib/crypto'
|
||||||
|
import { checkSuppression } from '../../../../../server/suppression/check'
|
||||||
function getTenantId(req: NextRequest): string {
|
import { getTenantId } from '../../../../../lib/tenant-header'
|
||||||
return req.headers.get('x-tenant-id') ?? 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
const tenantId = getTenantId(req)
|
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 })
|
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) =>
|
const recipients = await withTenant(tenantId, (client) =>
|
||||||
client.query<{ email: string }>(
|
client.query<{ email: string }>(
|
||||||
@@ -31,14 +33,32 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
// Suppression-Check PFLICHT — kein Opt-out-Empfänger darf in die Queue (Fix C3)
|
||||||
await enqueueEmailSend({
|
const unsuppressedRecipients = await Promise.all(
|
||||||
tenantId,
|
recipients.map(async (r) => {
|
||||||
campaignId: params.id,
|
const suppressed = await checkSuppression(tenantId, r.email)
|
||||||
recipientEmail: recipient.email,
|
return suppressed ? null : r
|
||||||
recipientHash: hashEmail(recipient.email),
|
|
||||||
})
|
})
|
||||||
}
|
).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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { CreateCampaignSchema } from '../../../lib/validation'
|
import { CreateCampaignSchema } from '../../../lib/validation'
|
||||||
import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
|
import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
|
||||||
|
import { getTenantId } from '../../../lib/tenant-header'
|
||||||
function getTenantId(req: NextRequest): string {
|
|
||||||
return req.headers.get('x-tenant-id') ?? 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const tenantId = getTenantId(req)
|
const tenantId = getTenantId(req)
|
||||||
|
|||||||
5
src/lib/tenant-header.ts
Normal file
5
src/lib/tenant-header.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export function getTenantId(req: NextRequest): string {
|
||||||
|
return req.headers.get('x-tenant-id') ?? 'default'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user