From 4174b330161a1e7b46b7ca1842ae86a0d6465698 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Fri, 17 Apr 2026 17:37:30 +0000 Subject: [PATCH] feat: Subscriber-Repository mit Double-Opt-In und Consent-Tracking (Finding 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createSubscriber: SHA256-Hash, DOI-Token, Consent-IP/UA - confirmDoi: setzt status=active, löscht Token - deleteSubscriber: DSGVO Art. 17 Co-Authored-By: Claude Sonnet 4.6 --- src/server/db/subscribers.test.ts | 132 ++++++++++++++++++++++++++++++ src/server/db/subscribers.ts | 85 +++++++++++++++++++ src/types/index.ts | 24 ++++++ 3 files changed, 241 insertions(+) create mode 100644 src/server/db/subscribers.test.ts create mode 100644 src/server/db/subscribers.ts diff --git a/src/server/db/subscribers.test.ts b/src/server/db/subscribers.test.ts new file mode 100644 index 0000000..93fc352 --- /dev/null +++ b/src/server/db/subscribers.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockClient = vi.hoisted(() => ({ + execute: vi.fn(), + query: vi.fn(), +})) + +vi.mock('./tenant', () => ({ + withTenant: vi.fn((_, fn) => fn(mockClient)), +})) + +import { createSubscriber, confirmDoi, deleteSubscriber } from './subscribers' + +const mockRow = { + id: 'sub-uuid-1', + email: 'test@example.com', + email_hash: 'abc123hash', + status: 'pending', + list_id: null, + doi_token: 'token-uuid', + doi_requested_at: new Date('2026-04-17').toISOString(), + doi_confirmed_at: null, + consent_ip: '1.2.3.4', + consent_user_agent: 'Mozilla/5.0', + created_at: new Date('2026-04-17').toISOString(), + updated_at: new Date('2026-04-17').toISOString(), +} + +describe('createSubscriber', () => { + beforeEach(() => mockClient.query.mockReset()) + + it('legt Subscriber im status=pending an', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + const result = await createSubscriber('tenant1', { + email: 'test@example.com', + consentIp: '1.2.3.4', + consentUserAgent: 'Mozilla/5.0', + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.status).toBe('pending') + }) + + it('speichert email_hash statt Klartext in Query-Params', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + await createSubscriber('tenant1', { + email: 'Test@Example.com', + consentIp: '1.2.3.4', + consentUserAgent: 'UA', + }) + const params = mockClient.query.mock.calls[0][1] as unknown[] + const hashParam = params.find((p) => typeof p === 'string' && /^[a-f0-9]{64}$/.test(p)) + expect(hashParam).toBeDefined() + }) + + it('sendet Klartext-E-Mail nie als einzigen Parameter', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + await createSubscriber('tenant1', { + email: 'secret@example.com', + consentIp: '1.2.3.4', + consentUserAgent: 'UA', + }) + const params = mockClient.query.mock.calls[0][1] as unknown[] + // E-Mail darf vorkommen (für SMTP-Zwecke im subscribers-Record), + // aber der Hash muss ebenfalls vorhanden sein + const hasHash = params.some((p) => typeof p === 'string' && /^[a-f0-9]{64}$/.test(p)) + expect(hasHash).toBe(true) + }) + + it('speichert consentIp und consentUserAgent', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + await createSubscriber('tenant1', { + email: 'test@example.com', + consentIp: '9.9.9.9', + consentUserAgent: 'TestAgent', + }) + const params = mockClient.query.mock.calls[0][1] as unknown[] + expect(params).toContain('9.9.9.9') + expect(params).toContain('TestAgent') + }) + + it('gibt err zurück wenn E-Mail bereits registriert (ON CONFLICT)', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await createSubscriber('tenant1', { + email: 'exists@example.com', + consentIp: '1.2.3.4', + consentUserAgent: 'UA', + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('bereits registriert') + }) +}) + +describe('confirmDoi', () => { + beforeEach(() => mockClient.query.mockReset()) + + it('setzt status=active bei gültigem Token', async () => { + mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'active', doi_token: null, doi_confirmed_at: new Date().toISOString() }]) + const result = await confirmDoi('tenant1', 'valid-token') + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.status).toBe('active') + }) + + it('gibt err zurück bei ungültigem Token', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await confirmDoi('tenant1', 'wrong-token') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('Ungültiger') + }) + + it('löscht doi_token nach Bestätigung', async () => { + mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'active', doi_token: null }]) + const result = await confirmDoi('tenant1', 'valid-token') + if (result.ok) expect(result.data.doiToken).toBeNull() + }) +}) + +describe('deleteSubscriber', () => { + beforeEach(() => mockClient.query.mockReset()) + + it('löscht Subscriber und gibt ok zurück', async () => { + mockClient.query.mockResolvedValueOnce([{ id: 'sub-uuid-1' }]) + const result = await deleteSubscriber('tenant1', 'sub-uuid-1') + expect(result.ok).toBe(true) + }) + + it('gibt err zurück wenn Subscriber nicht existiert', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await deleteSubscriber('tenant1', 'not-found') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('nicht gefunden') + }) +}) diff --git a/src/server/db/subscribers.ts b/src/server/db/subscribers.ts new file mode 100644 index 0000000..4ffc6ca --- /dev/null +++ b/src/server/db/subscribers.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'crypto' +import { withTenant } from './tenant' +import { ok, err, type Result } from '../../lib/result' +import { hashEmail } from '../../lib/crypto' +import type { Subscriber, CreateSubscriberInput } from '../../types' + +function rowToSubscriber(row: Record): Subscriber { + return { + id: row.id as string, + email: row.email as string, + emailHash: row.email_hash as string, + status: row.status as Subscriber['status'], + listId: row.list_id as string | null, + doiToken: row.doi_token as string | null, + doiRequestedAt: row.doi_requested_at ? new Date(row.doi_requested_at as string) : null, + doiConfirmedAt: row.doi_confirmed_at ? new Date(row.doi_confirmed_at as string) : null, + consentIp: row.consent_ip as string | null, + consentUserAgent: row.consent_user_agent as string | null, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } +} + +export async function createSubscriber( + tenantId: string, + input: CreateSubscriberInput +): Promise> { + try { + const emailHash = hashEmail(input.email) + const doiToken = randomUUID() + const rows = await withTenant(tenantId, (client) => + client.query( + `INSERT INTO subscribers + (email, email_hash, status, list_id, doi_token, doi_requested_at, consent_ip, consent_user_agent) + VALUES ($1, $2, 'pending', $3, $4, now(), $5, $6) + ON CONFLICT (email_hash) DO NOTHING + RETURNING *`, + [input.email, emailHash, input.listId ?? null, doiToken, input.consentIp, input.consentUserAgent] + ) + ) + if (rows.length === 0) return err(new Error('E-Mail bereits registriert')) + return ok(rowToSubscriber(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function confirmDoi( + tenantId: string, + token: string +): Promise> { + try { + const rows = await withTenant(tenantId, (client) => + client.query( + `UPDATE subscribers + SET status = 'active', doi_confirmed_at = now(), doi_token = NULL + WHERE doi_token = $1 AND status = 'pending' + RETURNING *`, + [token] + ) + ) + if (rows.length === 0) return err(new Error('Ungültiger oder abgelaufener DOI-Token')) + return ok(rowToSubscriber(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function deleteSubscriber( + tenantId: string, + subscriberId: string +): Promise> { + try { + const rows = await withTenant(tenantId, (client) => + client.query( + 'DELETE FROM subscribers WHERE id = $1 RETURNING id', + [subscriberId] + ) + ) + if (rows.length === 0) return err(new Error('Subscriber nicht gefunden')) + return ok(undefined) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 2491f9c..20ed991 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,30 @@ export interface CampaignTrigger { triggerValue: string } +export type SubscriberStatus = 'pending' | 'active' | 'unsubscribed' | 'bounced' + +export interface Subscriber { + id: string + email: string + emailHash: string + status: SubscriberStatus + listId: string | null + doiToken: string | null + doiRequestedAt: Date | null + doiConfirmedAt: Date | null + consentIp: string | null + consentUserAgent: string | null + createdAt: Date + updatedAt: Date +} + +export interface CreateSubscriberInput { + email: string + listId?: string + consentIp: string + consentUserAgent: string +} + export interface CampaignAnalytics { campaignId: string sent: number