diff --git a/migrations/pg/2026-04-17_fix-campaign-recipients-constraint.sql b/migrations/pg/2026-04-17_fix-campaign-recipients-constraint.sql new file mode 100644 index 0000000..0c05ad0 --- /dev/null +++ b/migrations/pg/2026-04-17_fix-campaign-recipients-constraint.sql @@ -0,0 +1,12 @@ +-- Verhindert campaign_recipients-Zeilen ohne list_id UND segment_id +-- NOT VALID: läuft ohne Table-Lock, bestehende Zeilen beim nächsten VALIDATE CONSTRAINT prüfen + +ALTER TABLE campaign_recipients + DROP CONSTRAINT IF EXISTS recipient_has_one; + +ALTER TABLE campaign_recipients + ADD CONSTRAINT recipient_has_one CHECK ( + (list_id IS NOT NULL AND segment_id IS NULL) + OR + (segment_id IS NOT NULL AND list_id IS NULL) + ) NOT VALID; diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts index 7d28135..1b2b4e4 100644 --- a/src/app/api/campaigns/[id]/route.ts +++ b/src/app/api/campaigns/[id]/route.ts @@ -4,7 +4,11 @@ import { getCampaign, updateCampaign } from '../../../../server/db/campaigns' import { getTenantId } from '../../../../lib/tenant-header' export async function GET(req: NextRequest, { params }: { params: { id: string } }) { - const result = await getCampaign(getTenantId(req), params.id) + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } + const result = await getCampaign(tenantId, params.id) if (!result.ok) { const status = result.error.message.includes('nicht gefunden') ? 404 : 500 return NextResponse.json({ error: result.error.message }, { status }) @@ -13,7 +17,10 @@ export async function GET(req: NextRequest, { params }: { params: { id: string } } export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { - const tenantId = getTenantId(req) + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } const body = await req.json() const parsed = UpdateCampaignSchema.safeParse(body) if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) diff --git a/src/app/api/campaigns/[id]/schedule/route.ts b/src/app/api/campaigns/[id]/schedule/route.ts index 63dac82..009df29 100644 --- a/src/app/api/campaigns/[id]/schedule/route.ts +++ b/src/app/api/campaigns/[id]/schedule/route.ts @@ -4,7 +4,10 @@ import { getCampaign, updateCampaignStatus } from '../../../../../server/db/camp import { getTenantId } from '../../../../../lib/tenant-header' export async function POST(req: NextRequest, { params }: { params: { id: string } }) { - const tenantId = getTenantId(req) + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } const body = await req.json() const parsed = ScheduleCampaignSchema.safeParse(body) if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) diff --git a/src/app/api/campaigns/[id]/send/route.ts b/src/app/api/campaigns/[id]/send/route.ts index 5ef3de2..5aac37f 100644 --- a/src/app/api/campaigns/[id]/send/route.ts +++ b/src/app/api/campaigns/[id]/send/route.ts @@ -7,7 +7,10 @@ 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) + 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) { diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index af67f68..907b5c4 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -4,14 +4,20 @@ import { createCampaign, listCampaigns } from '../../../server/db/campaigns' import { getTenantId } from '../../../lib/tenant-header' export async function GET(req: NextRequest) { - const tenantId = getTenantId(req) + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } const result = await listCampaigns(tenantId) if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 }) return NextResponse.json(result.data) } export async function POST(req: NextRequest) { - const tenantId = getTenantId(req) + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } const body = await req.json() const parsed = CreateCampaignSchema.safeParse(body) if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) diff --git a/src/lib/tenant-header.test.ts b/src/lib/tenant-header.test.ts new file mode 100644 index 0000000..d3618c8 --- /dev/null +++ b/src/lib/tenant-header.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { NextRequest } from 'next/server' +import { getTenantId } from './tenant-header' + +describe('getTenantId', () => { + it('gibt tenantId zurück wenn Header gesetzt', () => { + const req = new NextRequest('http://localhost/api', { + headers: { 'x-tenant-id': 'abc123' }, + }) + expect(getTenantId(req)).toBe('abc123') + }) + + it('wirft Fehler wenn x-tenant-id fehlt', () => { + const req = new NextRequest('http://localhost/api') + expect(() => getTenantId(req)).toThrow('x-tenant-id Header fehlt') + }) +}) diff --git a/src/lib/tenant-header.ts b/src/lib/tenant-header.ts index 6b5717b..56b721a 100644 --- a/src/lib/tenant-header.ts +++ b/src/lib/tenant-header.ts @@ -1,5 +1,7 @@ import { NextRequest } from 'next/server' export function getTenantId(req: NextRequest): string { - return req.headers.get('x-tenant-id') ?? 'default' + const tenantId = req.headers.get('x-tenant-id') + if (!tenantId) throw new Error('x-tenant-id Header fehlt') + return tenantId } diff --git a/src/queues/email-send.worker.test.ts b/src/queues/email-send.worker.test.ts index f07cd65..c401d7b 100644 --- a/src/queues/email-send.worker.test.ts +++ b/src/queues/email-send.worker.test.ts @@ -40,7 +40,10 @@ const jobData = { } describe('processEmailSendJob', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + process.env.APP_URL = 'http://localhost:3000' + }) it('sendet E-Mail wenn nicht suppressed', async () => { vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign }) @@ -133,4 +136,17 @@ describe('processEmailSendJob', () => { expect(result.ok).toBe(true) }) + + it('gibt err zurück wenn APP_URL nicht gesetzt', async () => { + const originalUrl = process.env.APP_URL + delete process.env.APP_URL + vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign }) + vi.mocked(checkSuppression).mockResolvedValue(false) + + const result = await processEmailSendJob(jobData) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('APP_URL') + process.env.APP_URL = originalUrl + }) }) diff --git a/src/queues/email-send.worker.ts b/src/queues/email-send.worker.ts index 8b97232..ed1aedb 100644 --- a/src/queues/email-send.worker.ts +++ b/src/queues/email-send.worker.ts @@ -18,7 +18,8 @@ export async function processEmailSendJob(data: EmailSendJobData): Promise