feat: Subscriber-Repository mit Double-Opt-In und Consent-Tracking (Finding 3)
- 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 <noreply@anthropic.com>
This commit is contained in:
132
src/server/db/subscribers.test.ts
Normal file
132
src/server/db/subscribers.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
85
src/server/db/subscribers.ts
Normal file
85
src/server/db/subscribers.ts
Normal file
@@ -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<string, unknown>): 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<Result<Subscriber>> {
|
||||
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<Result<Subscriber>> {
|
||||
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<Result<void>> {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user