diff --git a/src/app/api/subscribers/[id]/route.ts b/src/app/api/subscribers/[id]/route.ts new file mode 100644 index 0000000..6db297c --- /dev/null +++ b/src/app/api/subscribers/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import { deleteSubscriber } from '../../../../server/db/subscribers' +import { getTenantId } from '../../../../lib/tenant-header' + +export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } + + const result = await deleteSubscriber(tenantId, params.id) + if (!result.ok) { + const status = result.error.message.includes('nicht gefunden') ? 404 : 500 + return NextResponse.json({ error: result.error.message }, { status }) + } + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/subscribers/confirm/route.ts b/src/app/api/subscribers/confirm/route.ts new file mode 100644 index 0000000..6db18b1 --- /dev/null +++ b/src/app/api/subscribers/confirm/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { confirmDoi } from '../../../../server/db/subscribers' +import { getTenantId } from '../../../../lib/tenant-header' + +const ConfirmSchema = z.object({ token: z.string().uuid() }) + +export async function POST(req: NextRequest) { + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } + + const body = await req.json() as unknown + const parsed = ConfirmSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) + + const result = await confirmDoi(tenantId, parsed.data.token) + if (!result.ok) return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link' }, { status: 400 }) + + return NextResponse.json({ status: 'active' }) +} diff --git a/src/app/api/subscribers/route.ts b/src/app/api/subscribers/route.ts new file mode 100644 index 0000000..ba3644d --- /dev/null +++ b/src/app/api/subscribers/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createSubscriber } from '../../../server/db/subscribers' +import { getTenantId } from '../../../lib/tenant-header' + +const SubscribeSchema = z.object({ + email: z.string().email(), + listId: z.string().uuid().optional(), +}) + +export async function POST(req: NextRequest) { + let tenantId: string + try { tenantId = getTenantId(req) } catch { + return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 }) + } + + const body = await req.json() as unknown + const parsed = SubscribeSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) + + const consentIp = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? 'unbekannt' + const consentUserAgent = req.headers.get('user-agent') ?? 'unbekannt' + + const result = await createSubscriber(tenantId, { + email: parsed.data.email, + listId: parsed.data.listId, + consentIp, + consentUserAgent, + }) + + if (!result.ok) { + const status = result.error.message.includes('bereits registriert') ? 409 : 500 + return NextResponse.json({ error: result.error.message }, { status }) + } + + // Antwort enthält keine E-Mail-Adresse (DSGVO) + return NextResponse.json({ id: result.data.id, status: result.data.status }, { status: 201 }) +} diff --git a/src/app/api/subscribers/subscribers-api.test.ts b/src/app/api/subscribers/subscribers-api.test.ts new file mode 100644 index 0000000..b991e2d --- /dev/null +++ b/src/app/api/subscribers/subscribers-api.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('../../../server/db/subscribers', () => ({ + createSubscriber: vi.fn(), + confirmDoi: vi.fn(), + deleteSubscriber: vi.fn(), +})) +vi.mock('../../../lib/tenant-header', () => ({ + getTenantId: vi.fn().mockReturnValue('tenant1'), +})) + +import { createSubscriber, confirmDoi, deleteSubscriber } from '../../../server/db/subscribers' +import { getTenantId } from '../../../lib/tenant-header' + +describe('POST /api/subscribers', () => { + beforeEach(() => { + vi.mocked(getTenantId).mockReturnValue('tenant1') + vi.mocked(createSubscriber).mockReset() + }) + + it('gibt 201 zurück bei gültiger Anmeldung', async () => { + vi.mocked(createSubscriber).mockResolvedValueOnce({ + ok: true, + data: { id: 'sub-1', status: 'pending' } as never, + }) + const { POST } = await import('./route') + const req = new NextRequest('http://localhost/api/subscribers', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1', 'x-forwarded-for': '1.2.3.4' }, + body: JSON.stringify({ email: 'new@example.com' }), + }) + const res = await POST(req) + expect(res.status).toBe(201) + }) + + it('Antwort enthält keine E-Mail-Adresse (DSGVO)', async () => { + vi.mocked(createSubscriber).mockResolvedValueOnce({ + ok: true, + data: { id: 'sub-1', status: 'pending', email: 'secret@example.com' } as never, + }) + const { POST } = await import('./route') + const req = new NextRequest('http://localhost/api/subscribers', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({ email: 'secret@example.com' }), + }) + const res = await POST(req) + const body = await res.json() as Record + expect(JSON.stringify(body)).not.toContain('secret@example.com') + }) + + it('gibt 409 zurück wenn E-Mail bereits existiert', async () => { + vi.mocked(createSubscriber).mockResolvedValueOnce({ + ok: false, + error: new Error('E-Mail bereits registriert'), + }) + const { POST } = await import('./route') + const req = new NextRequest('http://localhost/api/subscribers', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({ email: 'exists@example.com' }), + }) + const res = await POST(req) + expect(res.status).toBe(409) + }) + + it('gibt 400 zurück bei ungültiger E-Mail', async () => { + const { POST } = await import('./route') + const req = new NextRequest('http://localhost/api/subscribers', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({ email: 'kein-email' }), + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) + + it('gibt 401 zurück wenn Tenant fehlt', async () => { + vi.mocked(getTenantId).mockImplementationOnce(() => { throw new Error('fehlt') }) + const { POST } = await import('./route') + const req = new NextRequest('http://localhost/api/subscribers', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'x@x.com' }), + }) + const res = await POST(req) + expect(res.status).toBe(401) + }) +}) + +describe('POST /api/subscribers/confirm', () => { + beforeEach(() => { + vi.mocked(getTenantId).mockReturnValue('tenant1') + vi.mocked(confirmDoi).mockReset() + }) + + it('gibt 200 bei gültigem Token', async () => { + vi.mocked(confirmDoi).mockResolvedValueOnce({ + ok: true, + data: { status: 'active' } as never, + }) + const { POST } = await import('./confirm/route') + const req = new NextRequest('http://localhost/api/subscribers/confirm', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({ token: '550e8400-e29b-41d4-a716-446655440000' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + }) + + it('gibt 400 bei ungültigem Token', async () => { + vi.mocked(confirmDoi).mockResolvedValueOnce({ + ok: false, + error: new Error('Ungültiger Token'), + }) + const { POST } = await import('./confirm/route') + const req = new NextRequest('http://localhost/api/subscribers/confirm', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({ token: '550e8400-e29b-41d4-a716-446655440000' }), + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) + + it('gibt 400 bei fehlendem Token-Feld', async () => { + const { POST } = await import('./confirm/route') + const req = new NextRequest('http://localhost/api/subscribers/confirm', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' }, + body: JSON.stringify({}), + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) + +describe('DELETE /api/subscribers/[id]', () => { + beforeEach(() => { + vi.mocked(getTenantId).mockReturnValue('tenant1') + vi.mocked(deleteSubscriber).mockReset() + }) + + it('gibt 204 bei erfolgreichem Löschen', async () => { + vi.mocked(deleteSubscriber).mockResolvedValueOnce({ ok: true, data: undefined }) + const { DELETE } = await import('./[id]/route') + const req = new NextRequest('http://localhost/api/subscribers/sub-1', { + method: 'DELETE', + headers: { 'x-tenant-id': 'tenant1' }, + }) + const res = await DELETE(req, { params: { id: 'sub-1' } }) + expect(res.status).toBe(204) + }) + + it('gibt 404 wenn Subscriber nicht existiert', async () => { + vi.mocked(deleteSubscriber).mockResolvedValueOnce({ + ok: false, + error: new Error('Subscriber nicht gefunden'), + }) + const { DELETE } = await import('./[id]/route') + const req = new NextRequest('http://localhost/api/subscribers/missing', { + method: 'DELETE', + headers: { 'x-tenant-id': 'tenant1' }, + }) + const res = await DELETE(req, { params: { id: 'missing' } }) + expect(res.status).toBe(404) + }) +})