Files
coding-starter/docs/plans/2026-04-17-kampagnen-implementierung.md

1921 lines
53 KiB
Markdown

# Kampagnen-Funktion Implementierungsplan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Vollständige Kampagnen-Funktion für Newsletter-SaaS — Erstellen, Versenden (sofort/geplant/Cron), Empfänger (Liste/Segment), Analytics.
**Architecture:** Schema-per-Tenant in PostgreSQL via `withTenant()`, Versand immer über BullMQ-Queue mit Suppression-Check, Analytics-Events append-only in ClickHouse. Next.js App Router mit Server Components und Server Actions.
**Tech Stack:** Next.js 14, TypeScript strict, Tailwind, shadcn/ui, PostgreSQL, ClickHouse (@clickhouse/client), BullMQ + Redis, Vitest, Mailhog (Tests)
---
## Task 1: Projektstruktur & Core Utilities
**Files:**
- Create: `src/lib/result.ts`
- Create: `src/lib/crypto.ts`
- Create: `src/lib/validation.ts`
- Create: `src/types/index.ts`
- Test: `src/lib/result.test.ts`
- Test: `src/lib/crypto.test.ts`
**Step 1: Failing Test schreiben — Result Pattern**
```typescript
// src/lib/result.test.ts
import { describe, it, expect } from 'vitest'
import { ok, err } from './result'
describe('Result', () => {
it('ok wraps data', () => {
const r = ok('hello')
expect(r.ok).toBe(true)
if (r.ok) expect(r.data).toBe('hello')
})
it('err wraps error', () => {
const r = err(new Error('fail'))
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error.message).toBe('fail')
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/lib/result.test.ts
```
Expected: FAIL — "Cannot find module './result'"
**Step 3: Result Pattern implementieren**
```typescript
// src/lib/result.ts
export type Result<T, E = Error> =
| { ok: true; data: T }
| { ok: false; error: E }
export const ok = <T>(data: T): Result<T> => ({ ok: true, data })
export const err = <E = Error>(error: E): Result<never, E> => ({ ok: false, error })
```
**Step 4: Test ausführen — muss grün sein**
```bash
pnpm test src/lib/result.test.ts
```
Expected: PASS
**Step 5: Failing Test schreiben — Crypto**
```typescript
// src/lib/crypto.test.ts
import { describe, it, expect } from 'vitest'
import { hashEmail } from './crypto'
describe('hashEmail', () => {
it('gibt SHA256-Hash zurück', () => {
const hash = hashEmail('test@example.com')
expect(hash).toHaveLength(64)
expect(hash).toMatch(/^[a-f0-9]+$/)
})
it('normalisiert E-Mail vor Hash (lowercase)', () => {
expect(hashEmail('Test@Example.COM')).toBe(hashEmail('test@example.com'))
})
it('gibt nie die Klartext-E-Mail zurück', () => {
const hash = hashEmail('test@example.com')
expect(hash).not.toContain('@')
})
})
```
**Step 6: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/lib/crypto.test.ts
```
Expected: FAIL
**Step 7: Crypto implementieren**
```typescript
// src/lib/crypto.ts
import { createHash } from 'crypto'
export function hashEmail(email: string): string {
return createHash('sha256').update(email.toLowerCase().trim()).digest('hex')
}
```
**Step 8: Test ausführen — muss grün sein**
```bash
pnpm test src/lib/crypto.test.ts
```
Expected: PASS
**Step 9: Globale Typen anlegen**
```typescript
// src/types/index.ts
export type CampaignStatus =
| 'draft'
| 'scheduled'
| 'sending'
| 'sent'
| 'paused'
| 'cancelled'
export type TriggerType = 'cron' | 'event'
export interface Campaign {
id: string
name: string
subject: string
htmlBody: string
plainBody: string
status: CampaignStatus
scheduledAt: Date | null
cronExpression: string | null
createdAt: Date
updatedAt: Date
}
export interface CampaignRecipient {
id: string
campaignId: string
listId: string | null
segmentId: string | null
}
export interface CampaignTrigger {
id: string
campaignId: string
triggerType: TriggerType
triggerValue: string
}
export interface CampaignAnalytics {
campaignId: string
sent: number
opens: number
clicks: number
bounces: number
unsubscribes: number
openRate: number
clickRate: number
bounceRate: number
}
```
**Step 10: Validierungsschemas anlegen**
```typescript
// src/lib/validation.ts
import { z } from 'zod'
export const CreateCampaignSchema = z.object({
name: z.string().min(1).max(255),
subject: z.string().min(1).max(998),
htmlBody: z.string().min(1),
plainBody: z.string().min(1),
})
export const UpdateCampaignSchema = CreateCampaignSchema.partial()
export const ScheduleCampaignSchema = z.union([
z.object({ type: z.literal('immediate') }),
z.object({ type: z.literal('once'), scheduledAt: z.coerce.date() }),
z.object({ type: z.literal('cron'), cronExpression: z.string().min(9) }),
])
export const RecipientSchema = z.union([
z.object({ listId: z.string().uuid(), segmentId: z.null().optional() }),
z.object({ segmentId: z.string().uuid(), listId: z.null().optional() }),
])
```
**Step 11: Commit**
```bash
git add src/lib/result.ts src/lib/result.test.ts src/lib/crypto.ts src/lib/crypto.test.ts src/lib/validation.ts src/types/index.ts
git commit -m "feat: core utilities — Result pattern, email hashing, Zod schemas, Campaign types"
```
---
## Task 2: Datenbankclients (PostgreSQL + ClickHouse)
**Files:**
- Create: `src/server/db/client.ts`
- Create: `src/server/db/tenant.ts`
- Create: `src/server/clickhouse/client.ts`
- Test: `src/server/db/tenant.test.ts`
**Step 1: Failing Test — withTenant**
```typescript
// src/server/db/tenant.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// DB-Client mocken
vi.mock('./client', () => ({
db: {
execute: vi.fn(),
query: vi.fn().mockResolvedValue({ rows: [{ id: '1' }] }),
},
}))
import { withTenant } from './tenant'
import { db } from './client'
describe('withTenant', () => {
beforeEach(() => vi.clearAllMocks())
it('setzt search_path auf tenant-Schema', async () => {
await withTenant('abc123', () => db.query('SELECT 1', []))
expect(db.execute).toHaveBeenCalledWith('SET search_path = tenant_abc123, public')
})
it('setzt search_path zurück auf public nach Ausführung', async () => {
await withTenant('abc123', () => db.query('SELECT 1', []))
expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public')
})
it('setzt search_path zurück auch bei Fehler', async () => {
const fn = vi.fn().mockRejectedValue(new Error('DB-Fehler'))
await expect(withTenant('abc123', fn)).rejects.toThrow('DB-Fehler')
expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public')
})
it('gibt Rückgabewert der Funktion zurück', async () => {
const result = await withTenant('abc123', async () => 'testdata')
expect(result).toBe('testdata')
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/server/db/tenant.test.ts
```
Expected: FAIL
**Step 3: DB-Client anlegen**
```typescript
// src/server/db/client.ts
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export const db = {
execute: (sql: string) => pool.query(sql),
query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
pool.query<T>(sql, params).then((r) => r.rows),
}
```
**Step 4: withTenant implementieren**
```typescript
// src/server/db/tenant.ts
import { db } from './client'
export async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
const schema = `tenant_${tenantId}`
await db.execute(`SET search_path = ${schema}, public`)
try {
return await fn()
} finally {
await db.execute(`SET search_path = public`)
}
}
```
**Step 5: Test ausführen — muss grün sein**
```bash
pnpm test src/server/db/tenant.test.ts
```
Expected: PASS
**Step 6: ClickHouse-Client anlegen**
```typescript
// src/server/clickhouse/client.ts
import { createClient } from '@clickhouse/client'
export const clickhouse = createClient({
url: process.env.CLICKHOUSE_URL,
username: process.env.CLICKHOUSE_USER,
password: process.env.CLICKHOUSE_PASSWORD,
database: 'newsletter',
})
```
**Step 7: Commit**
```bash
git add src/server/db/client.ts src/server/db/tenant.ts src/server/db/tenant.test.ts src/server/clickhouse/client.ts
git commit -m "feat: PostgreSQL withTenant helper und ClickHouse-Client"
```
---
## Task 3: Datenbank-Migrationen
**Files:**
- Create: `migrations/pg/2026-04-17_campaigns.sql`
- Create: `migrations/ch/2026-04-17_email_events.sql`
**Step 1: PostgreSQL-Migration erstellen**
```sql
-- migrations/pg/2026-04-17_campaigns.sql
-- Wird pro Tenant-Schema ausgeführt (SET search_path = tenant_<id>, public vorher)
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
subject TEXT NOT NULL,
html_body TEXT NOT NULL,
plain_body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','scheduled','sending','sent','paused','cancelled')),
scheduled_at TIMESTAMPTZ,
cron_expression TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS campaign_recipients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
list_id UUID,
segment_id UUID,
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)
)
);
CREATE TABLE IF NOT EXISTS campaign_triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('cron', 'event')),
trigger_value TEXT NOT NULL
);
-- updated_at automatisch aktualisieren
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER campaigns_updated_at
BEFORE UPDATE ON campaigns
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
**Step 2: ClickHouse-Migration erstellen**
```sql
-- migrations/ch/2026-04-17_email_events.sql
CREATE TABLE IF NOT EXISTS newsletter.email_events (
event_type LowCardinality(String),
tenant_id String,
campaign_id UUID,
recipient_hash String, -- SHA256, kein Klartext
timestamp DateTime64(3, 'UTC'),
metadata Map(String, String) -- optionale Felder (bounce_type, click_url, …)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (tenant_id, campaign_id, event_type, timestamp);
```
**Step 3: NICHT ausführen — nur prüfen ob Syntax korrekt ist**
```bash
# Nur Syntax-Check (psql dry-run):
psql $DATABASE_URL --single-transaction --dry-run -f migrations/pg/2026-04-17_campaigns.sql || echo "Syntax OK oder Fehler anzeigen"
```
**Step 4: Commit**
```bash
git add migrations/pg/2026-04-17_campaigns.sql migrations/ch/2026-04-17_email_events.sql
git commit -m "feat: Migrations für campaigns-Tabellen und ClickHouse email_events"
```
---
## Task 4: Campaign Repository (DB-Zugriffs-Layer)
**Files:**
- Create: `src/server/db/campaigns.ts`
- Test: `src/server/db/campaigns.test.ts`
**Step 1: Failing Tests schreiben**
```typescript
// src/server/db/campaigns.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('./client', () => ({
db: {
execute: vi.fn(),
query: vi.fn(),
},
}))
vi.mock('./tenant', () => ({
withTenant: vi.fn((_, fn) => fn()),
}))
import { db } from './client'
import {
createCampaign,
getCampaign,
updateCampaign,
listCampaigns,
updateCampaignStatus,
} from './campaigns'
const mockCampaign = {
id: 'uuid-1',
name: 'Test',
subject: 'Betreff',
html_body: '<p>Hi</p>',
plain_body: 'Hi',
status: 'draft',
scheduled_at: null,
cron_expression: null,
created_at: new Date(),
updated_at: new Date(),
}
describe('createCampaign', () => {
it('gibt erstellte Kampagne zurück', async () => {
vi.mocked(db.query).mockResolvedValueOnce([mockCampaign])
const result = await createCampaign('tenant1', {
name: 'Test',
subject: 'Betreff',
htmlBody: '<p>Hi</p>',
plainBody: 'Hi',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.name).toBe('Test')
})
})
describe('getCampaign', () => {
it('gibt Kampagne zurück wenn gefunden', async () => {
vi.mocked(db.query).mockResolvedValueOnce([mockCampaign])
const result = await getCampaign('tenant1', 'uuid-1')
expect(result.ok).toBe(true)
})
it('gibt err zurück wenn nicht gefunden', async () => {
vi.mocked(db.query).mockResolvedValueOnce([])
const result = await getCampaign('tenant1', 'uuid-1')
expect(result.ok).toBe(false)
})
})
describe('updateCampaignStatus', () => {
it('erlaubt gültigen Status-Übergang draft → scheduled', async () => {
vi.mocked(db.query).mockResolvedValueOnce([{ ...mockCampaign, status: 'scheduled' }])
const result = await updateCampaignStatus('tenant1', 'uuid-1', 'scheduled')
expect(result.ok).toBe(true)
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/server/db/campaigns.test.ts
```
Expected: FAIL
**Step 3: Repository implementieren**
```typescript
// src/server/db/campaigns.ts
import { db } from './client'
import { withTenant } from './tenant'
import { ok, err, type Result } from '../../lib/result'
import type { Campaign, CampaignStatus } from '../../types'
interface CreateCampaignInput {
name: string
subject: string
htmlBody: string
plainBody: string
}
function rowToCampaign(row: Record<string, unknown>): Campaign {
return {
id: row.id as string,
name: row.name as string,
subject: row.subject as string,
htmlBody: row.html_body as string,
plainBody: row.plain_body as string,
status: row.status as CampaignStatus,
scheduledAt: row.scheduled_at ? new Date(row.scheduled_at as string) : null,
cronExpression: row.cron_expression as string | null,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
}
}
export async function createCampaign(
tenantId: string,
input: CreateCampaignInput
): Promise<Result<Campaign>> {
try {
const rows = await withTenant(tenantId, () =>
db.query(
`INSERT INTO campaigns (name, subject, html_body, plain_body)
VALUES ($1, $2, $3, $4) RETURNING *`,
[input.name, input.subject, input.htmlBody, input.plainBody]
)
)
return ok(rowToCampaign(rows[0]))
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function getCampaign(
tenantId: string,
id: string
): Promise<Result<Campaign>> {
try {
const rows = await withTenant(tenantId, () =>
db.query('SELECT * FROM campaigns WHERE id = $1', [id])
)
if (rows.length === 0) return err(new Error('Kampagne nicht gefunden'))
return ok(rowToCampaign(rows[0]))
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function listCampaigns(tenantId: string): Promise<Result<Campaign[]>> {
try {
const rows = await withTenant(tenantId, () =>
db.query('SELECT * FROM campaigns ORDER BY created_at DESC', [])
)
return ok(rows.map(rowToCampaign))
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function updateCampaign(
tenantId: string,
id: string,
input: Partial<CreateCampaignInput>
): Promise<Result<Campaign>> {
try {
const fields: string[] = []
const values: unknown[] = []
let idx = 1
if (input.name !== undefined) { fields.push(`name = $${idx++}`); values.push(input.name) }
if (input.subject !== undefined) { fields.push(`subject = $${idx++}`); values.push(input.subject) }
if (input.htmlBody !== undefined) { fields.push(`html_body = $${idx++}`); values.push(input.htmlBody) }
if (input.plainBody !== undefined) { fields.push(`plain_body = $${idx++}`); values.push(input.plainBody) }
if (fields.length === 0) return err(new Error('Keine Felder zum Aktualisieren'))
values.push(id)
const rows = await withTenant(tenantId, () =>
db.query(
`UPDATE campaigns SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'draft' RETURNING *`,
values
)
)
if (rows.length === 0) return err(new Error('Kampagne nicht gefunden oder nicht im Draft-Status'))
return ok(rowToCampaign(rows[0]))
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function updateCampaignStatus(
tenantId: string,
id: string,
status: CampaignStatus,
extra?: { scheduledAt?: Date; cronExpression?: string }
): Promise<Result<Campaign>> {
try {
const rows = await withTenant(tenantId, () =>
db.query(
`UPDATE campaigns SET status = $1, scheduled_at = $2, cron_expression = $3
WHERE id = $4 RETURNING *`,
[status, extra?.scheduledAt ?? null, extra?.cronExpression ?? null, id]
)
)
if (rows.length === 0) return err(new Error('Kampagne nicht gefunden'))
return ok(rowToCampaign(rows[0]))
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
```
**Step 4: Test ausführen — muss grün sein**
```bash
pnpm test src/server/db/campaigns.test.ts
```
Expected: PASS
**Step 5: Commit**
```bash
git add src/server/db/campaigns.ts src/server/db/campaigns.test.ts
git commit -m "feat: Campaign Repository — CRUD mit Tenant-Isolation und Result-Pattern"
```
---
## Task 5: BullMQ E-Mail-Versand-Queue
**Files:**
- Create: `src/queues/email-send.queue.ts`
- Test: `src/queues/email-send.test.ts`
**Step 1: Failing Tests schreiben**
```typescript
// src/queues/email-send.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('bullmq', () => ({
Queue: vi.fn().mockImplementation(() => ({
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
})),
Worker: vi.fn(),
}))
vi.mock('../server/db/campaigns', () => ({
getCampaign: vi.fn(),
updateCampaignStatus: vi.fn(),
}))
vi.mock('../server/smtp/client', () => ({
sendEmail: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
}))
vi.mock('../server/suppression/check', () => ({
checkSuppression: vi.fn().mockResolvedValue(false),
}))
import { enqueueEmailSend, type EmailSendJobData } from './email-send.queue'
import { Queue } from 'bullmq'
describe('enqueueEmailSend', () => {
it('enqueued Job mit korrekten Daten', async () => {
const data: EmailSendJobData = {
tenantId: 'tenant1',
campaignId: 'campaign-uuid',
recipientEmail: 'empfaenger@example.com',
recipientHash: 'abc123',
}
const result = await enqueueEmailSend(data)
expect(result.ok).toBe(true)
const queueInstance = vi.mocked(Queue).mock.results[0].value
expect(queueInstance.add).toHaveBeenCalledWith(
'send',
data,
expect.objectContaining({ attempts: 3 })
)
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/queues/email-send.test.ts
```
Expected: FAIL
**Step 3: Queue implementieren**
```typescript
// src/queues/email-send.queue.ts
import { Queue } from 'bullmq'
import { ok, err, type Result } from '../lib/result'
export interface EmailSendJobData {
tenantId: string
campaignId: string
recipientEmail: string
recipientHash: string
}
const connection = {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
}
export const emailSendQueue = new Queue<EmailSendJobData>('email:send', { connection })
export async function enqueueEmailSend(data: EmailSendJobData): Promise<Result<string>> {
try {
const job = await emailSendQueue.add('send', data, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: { count: 500 },
})
return ok(job.id ?? 'unknown')
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
```
**Step 4: Test ausführen — muss grün sein**
```bash
pnpm test src/queues/email-send.test.ts
```
Expected: PASS
**Step 5: Commit**
```bash
git add src/queues/email-send.queue.ts src/queues/email-send.test.ts
git commit -m "feat: BullMQ email:send Queue — maxAttempts 3, exponentielles Backoff"
```
---
## Task 6: Suppression-Check & SMTP-Client
**Files:**
- Create: `src/server/suppression/check.ts`
- Create: `src/server/smtp/client.ts`
- Test: `src/server/suppression/check.test.ts`
- Test: `src/server/smtp/client.test.ts`
**Step 1: Failing Test — Suppression-Check**
```typescript
// src/server/suppression/check.test.ts
import { describe, it, expect, vi } from 'vitest'
vi.mock('../db/client', () => ({
db: { query: vi.fn() },
}))
vi.mock('../db/tenant', () => ({
withTenant: vi.fn((_, fn) => fn()),
}))
import { db } from '../db/client'
import { checkSuppression } from './check'
describe('checkSuppression', () => {
it('gibt true zurück wenn E-Mail in Suppression-Liste', async () => {
vi.mocked(db.query).mockResolvedValueOnce([{ email: 'test@example.com' }])
const result = await checkSuppression('tenant1', 'test@example.com')
expect(result).toBe(true)
})
it('gibt false zurück wenn E-Mail nicht in Suppression-Liste', async () => {
vi.mocked(db.query).mockResolvedValueOnce([])
const result = await checkSuppression('tenant1', 'clean@example.com')
expect(result).toBe(false)
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/server/suppression/check.test.ts
```
**Step 3: Suppression-Check implementieren**
```typescript
// src/server/suppression/check.ts
import { db } from '../db/client'
import { withTenant } from '../db/tenant'
export async function checkSuppression(tenantId: string, email: string): Promise<boolean> {
const rows = await withTenant(tenantId, () =>
db.query(
'SELECT 1 FROM suppression_list WHERE email = $1 LIMIT 1',
[email.toLowerCase().trim()]
)
)
return rows.length > 0
}
```
**Step 4: Test ausführen**
```bash
pnpm test src/server/suppression/check.test.ts
```
Expected: PASS
**Step 5: Failing Test — SMTP-Client**
```typescript
// src/server/smtp/client.test.ts
import { describe, it, expect, vi } from 'vitest'
vi.mock('nodemailer', () => ({
default: {
createTransport: vi.fn().mockReturnValue({
sendMail: vi.fn().mockResolvedValue({ messageId: 'test-id' }),
}),
},
}))
import { sendEmail } from './client'
describe('sendEmail', () => {
it('sendet E-Mail über SMTP', async () => {
const result = await sendEmail({
to: 'empfaenger@example.com',
subject: 'Test',
html: '<p>Hi</p>',
text: 'Hi',
listUnsubscribeHeader: '<https://example.com/unsub?id=1>',
})
expect(result.ok).toBe(true)
})
it('setzt List-Unsubscribe-Header (RFC 8058)', async () => {
import nodemailer from 'nodemailer'
const transport = vi.mocked(nodemailer.createTransport).mock.results[0].value
await sendEmail({
to: 'empfaenger@example.com',
subject: 'Test',
html: '<p>Hi</p>',
text: 'Hi',
listUnsubscribeHeader: '<https://example.com/unsub?id=1>',
})
expect(transport.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
'List-Unsubscribe': expect.stringContaining('unsub'),
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}),
})
)
})
})
```
**Step 6: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/server/smtp/client.test.ts
```
**Step 7: SMTP-Client implementieren**
```typescript
// src/server/smtp/client.ts
import nodemailer from 'nodemailer'
import { ok, err, type Result } from '../../lib/result'
interface SendEmailInput {
to: string
subject: string
html: string
text: string
listUnsubscribeHeader: string
}
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? 'localhost',
port: Number(process.env.SMTP_PORT ?? 1025),
secure: false,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
})
export async function sendEmail(input: SendEmailInput): Promise<Result<void>> {
try {
await transport.sendMail({
from: process.env.SMTP_FROM ?? 'newsletter@localhost',
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
headers: {
'List-Unsubscribe': input.listUnsubscribeHeader,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
})
return ok(undefined)
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
```
**Step 8: Tests ausführen**
```bash
pnpm test src/server/smtp/client.test.ts
```
Expected: PASS
**Step 9: Commit**
```bash
git add src/server/suppression/check.ts src/server/suppression/check.test.ts src/server/smtp/client.ts src/server/smtp/client.test.ts
git commit -m "feat: Suppression-Check und SMTP-Client mit RFC-8058-Header"
```
---
## Task 7: BullMQ Worker — E-Mail verarbeiten
**Files:**
- Create: `src/queues/email-send.worker.ts`
- Test: `src/queues/email-send.worker.test.ts`
**Step 1: Failing Tests**
```typescript
// src/queues/email-send.worker.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../server/suppression/check', () => ({
checkSuppression: vi.fn(),
}))
vi.mock('../server/smtp/client', () => ({
sendEmail: vi.fn(),
}))
vi.mock('../server/db/campaigns', () => ({
getCampaign: vi.fn(),
}))
vi.mock('../server/clickhouse/client', () => ({
clickhouse: { insert: vi.fn().mockResolvedValue(undefined) },
}))
import { processEmailSendJob } from './email-send.worker'
import { checkSuppression } from '../server/suppression/check'
import { sendEmail } from '../server/smtp/client'
import { getCampaign } from '../server/db/campaigns'
const mockCampaign = {
ok: true,
data: {
id: 'campaign-1',
subject: 'Newsletter',
htmlBody: '<p>Hi</p>',
plainBody: 'Hi',
},
}
describe('processEmailSendJob', () => {
beforeEach(() => vi.clearAllMocks())
it('sendet E-Mail wenn nicht suppressed', async () => {
vi.mocked(getCampaign).mockResolvedValue(mockCampaign as never)
vi.mocked(checkSuppression).mockResolvedValue(false)
vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined })
const result = await processEmailSendJob({
tenantId: 'tenant1',
campaignId: 'campaign-1',
recipientEmail: 'test@example.com',
recipientHash: 'abc123',
})
expect(result.ok).toBe(true)
expect(sendEmail).toHaveBeenCalledOnce()
})
it('überspringt Versand wenn Empfänger suppressed', async () => {
vi.mocked(getCampaign).mockResolvedValue(mockCampaign as never)
vi.mocked(checkSuppression).mockResolvedValue(true)
const result = await processEmailSendJob({
tenantId: 'tenant1',
campaignId: 'campaign-1',
recipientEmail: 'suppressed@example.com',
recipientHash: 'def456',
})
expect(result.ok).toBe(true)
expect(sendEmail).not.toHaveBeenCalled()
})
it('gibt err zurück wenn Kampagne nicht gefunden', async () => {
vi.mocked(getCampaign).mockResolvedValue({ ok: false, error: new Error('nicht gefunden') })
const result = await processEmailSendJob({
tenantId: 'tenant1',
campaignId: 'missing',
recipientEmail: 'test@example.com',
recipientHash: 'abc123',
})
expect(result.ok).toBe(false)
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/queues/email-send.worker.test.ts
```
**Step 3: Worker implementieren**
```typescript
// src/queues/email-send.worker.ts
import { Worker } from 'bullmq'
import { getCampaign } from '../server/db/campaigns'
import { checkSuppression } from '../server/suppression/check'
import { sendEmail } from '../server/smtp/client'
import { clickhouse } from '../server/clickhouse/client'
import { ok, err, type Result } from '../lib/result'
import type { EmailSendJobData } from './email-send.queue'
export async function processEmailSendJob(data: EmailSendJobData): Promise<Result<void>> {
const campaignResult = await getCampaign(data.tenantId, data.campaignId)
if (!campaignResult.ok) return err(campaignResult.error)
const campaign = campaignResult.data
// Suppression-Check ist PFLICHT — kein Opt-out-Empfänger darf E-Mail erhalten
const suppressed = await checkSuppression(data.tenantId, data.recipientEmail)
if (suppressed) {
await insertEvent('suppressed', data)
return ok(undefined)
}
const unsubUrl = `${process.env.APP_URL}/unsub?tid=${data.tenantId}&cid=${data.campaignId}&r=${data.recipientHash}`
const sendResult = await sendEmail({
to: data.recipientEmail,
subject: campaign.subject,
html: campaign.htmlBody,
text: campaign.plainBody,
listUnsubscribeHeader: `<${unsubUrl}>`,
})
if (!sendResult.ok) return err(sendResult.error)
await insertEvent('sent', data)
return ok(undefined)
}
async function insertEvent(eventType: string, data: EmailSendJobData): Promise<void> {
await clickhouse.insert({
table: 'email_events',
values: [{
event_type: eventType,
tenant_id: data.tenantId,
campaign_id: data.campaignId,
recipient_hash: data.recipientHash,
timestamp: new Date().toISOString(),
metadata: {},
}],
format: 'JSONEachRow',
})
}
const connection = {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
}
// Worker nur außerhalb von Tests starten
if (process.env.NODE_ENV !== 'test') {
new Worker<EmailSendJobData>(
'email:send',
async (job) => {
const result = await processEmailSendJob(job.data)
if (!result.ok) throw result.error
},
{
connection,
concurrency: 10,
}
)
}
```
**Step 4: Tests ausführen**
```bash
pnpm test src/queues/email-send.worker.test.ts
```
Expected: PASS
**Step 5: Commit**
```bash
git add src/queues/email-send.worker.ts src/queues/email-send.worker.test.ts
git commit -m "feat: BullMQ Worker — Suppression-Check vor Versand, ClickHouse-Event-Insert"
```
---
## Task 8: API Routes — Campaign CRUD
**Files:**
- Create: `src/app/api/campaigns/route.ts`
- Create: `src/app/api/campaigns/[id]/route.ts`
- Create: `src/app/api/campaigns/[id]/send/route.ts`
- Create: `src/app/api/campaigns/[id]/schedule/route.ts`
**Step 1: POST /api/campaigns**
```typescript
// src/app/api/campaigns/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { CreateCampaignSchema } from '../../../lib/validation'
import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
// Tenant-ID kommt aus OIDC-Session — hier placeholder bis Auth implementiert
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
export async function GET(req: NextRequest) {
const tenantId = getTenantId(req)
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)
const body = await req.json()
const parsed = CreateCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const result = await createCampaign(tenantId, parsed.data)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 })
return NextResponse.json(result.data, { status: 201 })
}
```
**Step 2: GET/PATCH /api/campaigns/[id]**
```typescript
// src/app/api/campaigns/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { UpdateCampaignSchema } from '../../../../lib/validation'
import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const result = await getCampaign(getTenantId(req), params.id)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 404 })
return NextResponse.json(result.data)
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
const body = await req.json()
const parsed = UpdateCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const result = await updateCampaign(tenantId, params.id, parsed.data)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 })
return NextResponse.json(result.data)
}
```
**Step 3: POST /api/campaigns/[id]/send**
```typescript
// src/app/api/campaigns/[id]/send/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns'
import { enqueueEmailSend } from '../../../../../queues/email-send.queue'
import { withTenant } from '../../../../../server/db/tenant'
import { db } from '../../../../../server/db/client'
import { hashEmail } from '../../../../../lib/crypto'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
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 versendet werden' }, { status: 400 })
}
// Status auf 'sending' setzen
await updateCampaignStatus(tenantId, params.id, 'sending')
// Empfänger auflösen und Jobs enqueuen
const recipients = await withTenant(tenantId, () =>
db.query(
`SELECT s.email FROM subscribers s
JOIN campaign_recipients cr ON cr.campaign_id = $1
WHERE (cr.list_id IS NULL OR s.list_id = cr.list_id)
AND s.status = 'active'`,
[params.id]
)
)
for (const recipient of recipients as Array<{ email: string }>) {
await enqueueEmailSend({
tenantId,
campaignId: params.id,
recipientEmail: recipient.email,
recipientHash: hashEmail(recipient.email),
})
}
return NextResponse.json({ queued: recipients.length })
}
```
**Step 4: POST /api/campaigns/[id]/schedule**
```typescript
// src/app/api/campaigns/[id]/schedule/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ScheduleCampaignSchema } from '../../../../../lib/validation'
import { updateCampaignStatus } from '../../../../../server/db/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
const body = await req.json()
const parsed = ScheduleCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const schedule = parsed.data
let scheduledAt: Date | undefined
let cronExpression: string | undefined
if (schedule.type === 'once') scheduledAt = schedule.scheduledAt
if (schedule.type === 'cron') cronExpression = schedule.cronExpression
const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', {
scheduledAt,
cronExpression,
})
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 })
return NextResponse.json(result.data)
}
```
**Step 5: Alle Tests ausführen**
```bash
pnpm test && pnpm lint
```
Expected: PASS
**Step 6: Commit**
```bash
git add src/app/api/campaigns/
git commit -m "feat: API Routes — Campaign CRUD, sofortiger Versand, Zeitplan-Endpoint"
```
---
## Task 9: Analytics — ClickHouse Query-Layer & API
**Files:**
- Create: `src/analytics/campaigns.ts`
- Create: `src/app/api/campaigns/[id]/analytics/route.ts`
- Test: `src/analytics/campaigns.test.ts`
**Step 1: Failing Tests**
```typescript
// src/analytics/campaigns.test.ts
import { describe, it, expect, vi } from 'vitest'
vi.mock('../server/clickhouse/client', () => ({
clickhouse: {
query: vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue([
{
event_type: 'sent', count: '100',
opens: '45', clicks: '12', bounces: '3', unsubscribes: '2',
},
]),
}),
},
}))
import { getCampaignAnalytics } from './campaigns'
describe('getCampaignAnalytics', () => {
it('gibt aggregierte Metriken zurück', async () => {
const result = await getCampaignAnalytics('tenant1', 'campaign-1')
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.openRate).toBeGreaterThanOrEqual(0)
expect(result.data.bounceRate).toBeGreaterThanOrEqual(0)
}
})
it('schließt keine Klartext-E-Mails ein', async () => {
const result = await getCampaignAnalytics('tenant1', 'campaign-1')
if (result.ok) {
const keys = Object.keys(result.data)
expect(keys).not.toContain('email')
expect(keys).not.toContain('recipientEmail')
}
})
})
```
**Step 2: Test ausführen — muss fehlschlagen**
```bash
pnpm test src/analytics/campaigns.test.ts
```
**Step 3: Analytics Query implementieren**
```typescript
// src/analytics/campaigns.ts
import { clickhouse } from '../server/clickhouse/client'
import { ok, err, type Result } from '../lib/result'
import type { CampaignAnalytics } from '../types'
export async function getCampaignAnalytics(
tenantId: string,
campaignId: string
): Promise<Result<CampaignAnalytics>> {
try {
const result = await clickhouse.query({
query: `
SELECT
countIf(event_type = 'sent') AS sent,
countIf(event_type = 'open') AS opens,
countIf(event_type = 'click') AS clicks,
countIf(event_type = 'bounce') AS bounces,
countIf(event_type = 'unsubscribe') AS unsubscribes
FROM email_events
WHERE tenant_id = {tenantId:String}
AND campaign_id = {campaignId:UUID}
`,
query_params: { tenantId, campaignId },
})
const rows = await result.json<Record<string, string>>()
const row = rows[0] ?? {}
const sent = Number(row.sent ?? 0)
const opens = Number(row.opens ?? 0)
const clicks = Number(row.clicks ?? 0)
const bounces = Number(row.bounces ?? 0)
const unsubscribes = Number(row.unsubscribes ?? 0)
return ok({
campaignId,
sent,
opens,
clicks,
bounces,
unsubscribes,
openRate: sent > 0 ? opens / sent : 0,
clickRate: sent > 0 ? clicks / sent : 0,
bounceRate: sent > 0 ? bounces / sent : 0,
})
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
```
**Step 4: Analytics API Route**
```typescript
// src/app/api/campaigns/[id]/analytics/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getCampaignAnalytics } from '../../../../../analytics/campaigns'
function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
}
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const result = await getCampaignAnalytics(getTenantId(req), params.id)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 })
return NextResponse.json(result.data)
}
```
**Step 5: Tests ausführen**
```bash
pnpm test src/analytics/campaigns.test.ts
```
Expected: PASS
**Step 6: Commit**
```bash
git add src/analytics/campaigns.ts src/analytics/campaigns.test.ts src/app/api/campaigns/[id]/analytics/
git commit -m "feat: ClickHouse Analytics-Query — Opens, Clicks, Bounces, Raten"
```
---
## Task 10: Frontend — Kampagnen-Übersicht
**Files:**
- Create: `src/components/email/CampaignCard.tsx`
- Create: `src/app/(dashboard)/campaigns/page.tsx`
**Step 1: CampaignCard-Komponente**
```tsx
// src/components/email/CampaignCard.tsx
import type { Campaign } from '../../types'
const STATUS_LABELS: Record<Campaign['status'], string> = {
draft: 'Entwurf',
scheduled: 'Geplant',
sending: 'Wird gesendet',
sent: 'Gesendet',
paused: 'Pausiert',
cancelled: 'Abgebrochen',
}
const STATUS_COLORS: Record<Campaign['status'], string> = {
draft: 'bg-gray-100 text-gray-700',
scheduled: 'bg-blue-100 text-blue-700',
sending: 'bg-yellow-100 text-yellow-700',
sent: 'bg-green-100 text-green-700',
paused: 'bg-orange-100 text-orange-700',
cancelled: 'bg-red-100 text-red-700',
}
interface CampaignCardProps {
campaign: Campaign
}
export function CampaignCard({ campaign }: CampaignCardProps) {
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-medium text-gray-900">{campaign.name}</h3>
<p className="mt-1 text-sm text-gray-500">{campaign.subject}</p>
</div>
<span className={`rounded-full px-2 py-1 text-xs font-medium ${STATUS_COLORS[campaign.status]}`}>
{STATUS_LABELS[campaign.status]}
</span>
</div>
<p className="mt-2 text-xs text-gray-400">
Erstellt: {campaign.createdAt.toLocaleDateString('de-DE')}
</p>
<div className="mt-3 flex gap-2">
<a
href={`/campaigns/${campaign.id}`}
className="text-sm text-blue-600 hover:underline"
>
Details
</a>
{campaign.status === 'draft' && (
<a
href={`/campaigns/${campaign.id}/edit`}
className="text-sm text-gray-600 hover:underline"
>
Bearbeiten
</a>
)}
<a
href={`/campaigns/${campaign.id}/analytics`}
className="text-sm text-gray-600 hover:underline"
>
Analytics
</a>
</div>
</div>
)
}
```
**Step 2: Kampagnen-Übersichtsseite**
```tsx
// src/app/(dashboard)/campaigns/page.tsx
import { headers } from 'next/headers'
import { listCampaigns } from '../../../server/db/campaigns'
import { CampaignCard } from '../../../components/email/CampaignCard'
export default async function CampaignsPage() {
const headersList = headers()
const tenantId = headersList.get('x-tenant-id') ?? 'default'
const result = await listCampaigns(tenantId)
const campaigns = result.ok ? result.data : []
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Kampagnen</h1>
<a
href="/campaigns/new"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
Neue Kampagne
</a>
</div>
{campaigns.length === 0 ? (
<p className="text-gray-500">Noch keine Kampagnen vorhanden.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{campaigns.map((c) => (
<CampaignCard key={c.id} campaign={c} />
))}
</div>
)}
</div>
)
}
```
**Step 3: Commit**
```bash
git add src/components/email/CampaignCard.tsx src/app/\(dashboard\)/campaigns/page.tsx
git commit -m "feat: Kampagnen-Übersicht — CampaignCard mit Status-Badge, Server Component"
```
---
## Task 11: Frontend — Kampagne erstellen (Wizard)
**Files:**
- Create: `src/app/(dashboard)/campaigns/new/page.tsx`
- Create: `src/components/email/CampaignEditor.tsx`
- Create: `src/components/email/RecipientPicker.tsx`
- Create: `src/components/email/SchedulePicker.tsx`
**Step 1: CampaignEditor**
```tsx
// src/components/email/CampaignEditor.tsx
'use client'
import { useState } from 'react'
interface CampaignEditorProps {
htmlBody: string
plainBody: string
onChangeHtml: (v: string) => void
onChangePlain: (v: string) => void
}
export function CampaignEditor({ htmlBody, plainBody, onChangeHtml, onChangePlain }: CampaignEditorProps) {
const [tab, setTab] = useState<'html' | 'plain'>('html')
return (
<div className="space-y-2">
<div className="flex gap-2 border-b">
<button
type="button"
onClick={() => setTab('html')}
className={`px-3 py-1 text-sm ${tab === 'html' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-500'}`}
>
HTML
</button>
<button
type="button"
onClick={() => setTab('plain')}
className={`px-3 py-1 text-sm ${tab === 'plain' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-500'}`}
>
Plain-Text
</button>
</div>
{tab === 'html' ? (
<textarea
value={htmlBody}
onChange={(e) => onChangeHtml(e.target.value)}
className="h-64 w-full rounded border p-2 font-mono text-sm"
placeholder="<p>Hallo {{name}},</p>"
/>
) : (
<textarea
value={plainBody}
onChange={(e) => onChangePlain(e.target.value)}
className="h-64 w-full rounded border p-2 font-mono text-sm"
placeholder="Hallo {{name}},"
/>
)}
</div>
)
}
```
**Step 2: RecipientPicker**
```tsx
// src/components/email/RecipientPicker.tsx
'use client'
import { useState } from 'react'
interface RecipientPickerProps {
onSelect: (value: { listId?: string; segmentId?: string }) => void
}
export function RecipientPicker({ onSelect }: RecipientPickerProps) {
const [mode, setMode] = useState<'list' | 'segment'>('list')
const [value, setValue] = useState('')
function handleChange(v: string) {
setValue(v)
if (mode === 'list') onSelect({ listId: v })
else onSelect({ segmentId: v })
}
return (
<div className="space-y-2">
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={mode === 'list'} onChange={() => setMode('list')} />
Feste Liste
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={mode === 'segment'} onChange={() => setMode('segment')} />
Dynamisches Segment
</label>
</div>
<input
type="text"
value={value}
onChange={(e) => handleChange(e.target.value)}
placeholder={mode === 'list' ? 'Listen-ID eingeben' : 'Segment-ID eingeben'}
className="w-full rounded border px-3 py-2 text-sm"
/>
</div>
)
}
```
**Step 3: SchedulePicker**
```tsx
// src/components/email/SchedulePicker.tsx
'use client'
import { useState } from 'react'
type ScheduleValue =
| { type: 'immediate' }
| { type: 'once'; scheduledAt: string }
| { type: 'cron'; cronExpression: string }
interface SchedulePickerProps {
onChange: (value: ScheduleValue) => void
}
export function SchedulePicker({ onChange }: SchedulePickerProps) {
const [mode, setMode] = useState<'immediate' | 'once' | 'cron'>('immediate')
function handleModeChange(m: typeof mode) {
setMode(m)
if (m === 'immediate') onChange({ type: 'immediate' })
}
return (
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row">
{(['immediate', 'once', 'cron'] as const).map((m) => (
<label key={m} className="flex items-center gap-2 text-sm">
<input type="radio" checked={mode === m} onChange={() => handleModeChange(m)} />
{{ immediate: 'Sofort', once: 'Einmalig geplant', cron: 'Wiederkehrend (Cron)' }[m]}
</label>
))}
</div>
{mode === 'once' && (
<input
type="datetime-local"
className="rounded border px-3 py-2 text-sm"
onChange={(e) => onChange({ type: 'once', scheduledAt: e.target.value })}
/>
)}
{mode === 'cron' && (
<input
type="text"
placeholder="0 9 * * 1 (jeden Montag 9 Uhr)"
className="w-full rounded border px-3 py-2 font-mono text-sm"
onChange={(e) => onChange({ type: 'cron', cronExpression: e.target.value })}
/>
)}
</div>
)
}
```
**Step 4: New Campaign Wizard-Seite**
```tsx
// src/app/(dashboard)/campaigns/new/page.tsx
'use client'
import { useState } from 'react'
import { CampaignEditor } from '../../../../components/email/CampaignEditor'
import { RecipientPicker } from '../../../../components/email/RecipientPicker'
import { SchedulePicker } from '../../../../components/email/SchedulePicker'
import { useRouter } from 'next/navigation'
type Step = 'meta' | 'content' | 'recipients' | 'schedule' | 'review'
export default function NewCampaignPage() {
const router = useRouter()
const [step, setStep] = useState<Step>('meta')
const [form, setForm] = useState({
name: '',
subject: '',
htmlBody: '',
plainBody: '',
recipient: {} as Record<string, string>,
schedule: { type: 'immediate' } as Record<string, unknown>,
})
async function handleSubmit() {
const res = await fetch('/api/campaigns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
subject: form.subject,
htmlBody: form.htmlBody,
plainBody: form.plainBody,
}),
})
if (!res.ok) return
const campaign = await res.json()
if (form.schedule.type === 'immediate') {
await fetch(`/api/campaigns/${campaign.id}/send`, { method: 'POST' })
} else {
await fetch(`/api/campaigns/${campaign.id}/schedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.schedule),
})
}
router.push('/campaigns')
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-semibold">Neue Kampagne</h1>
{step === 'meta' && (
<div className="space-y-4">
<input
className="w-full rounded border px-3 py-2"
placeholder="Kampagnen-Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<input
className="w-full rounded border px-3 py-2"
placeholder="Betreff"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
/>
<button
onClick={() => setStep('content')}
disabled={!form.name || !form.subject}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Weiter
</button>
</div>
)}
{step === 'content' && (
<div className="space-y-4">
<CampaignEditor
htmlBody={form.htmlBody}
plainBody={form.plainBody}
onChangeHtml={(v) => setForm({ ...form, htmlBody: v })}
onChangePlain={(v) => setForm({ ...form, plainBody: v })}
/>
<div className="flex gap-2">
<button onClick={() => setStep('meta')} className="rounded border px-4 py-2 text-sm">Zurück</button>
<button
onClick={() => setStep('recipients')}
disabled={!form.htmlBody || !form.plainBody}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
)}
{step === 'recipients' && (
<div className="space-y-4">
<RecipientPicker onSelect={(v) => setForm({ ...form, recipient: v as Record<string, string> })} />
<div className="flex gap-2">
<button onClick={() => setStep('content')} className="rounded border px-4 py-2 text-sm">Zurück</button>
<button onClick={() => setStep('schedule')} className="rounded bg-blue-600 px-4 py-2 text-sm text-white">Weiter</button>
</div>
</div>
)}
{step === 'schedule' && (
<div className="space-y-4">
<SchedulePicker onChange={(v) => setForm({ ...form, schedule: v as Record<string, unknown> })} />
<div className="flex gap-2">
<button onClick={() => setStep('recipients')} className="rounded border px-4 py-2 text-sm">Zurück</button>
<button onClick={() => setStep('review')} className="rounded bg-blue-600 px-4 py-2 text-sm text-white">Weiter</button>
</div>
</div>
)}
{step === 'review' && (
<div className="space-y-4">
<div className="rounded border p-4 space-y-2 text-sm">
<p><strong>Name:</strong> {form.name}</p>
<p><strong>Betreff:</strong> {form.subject}</p>
<p><strong>Versand:</strong> {String(form.schedule.type)}</p>
</div>
<div className="flex gap-2">
<button onClick={() => setStep('schedule')} className="rounded border px-4 py-2 text-sm">Zurück</button>
<button onClick={handleSubmit} className="rounded bg-green-600 px-4 py-2 text-sm text-white">
Kampagne erstellen & starten
</button>
</div>
</div>
)}
</div>
)
}
```
**Step 5: Alle Tests + Lint ausführen**
```bash
pnpm test && pnpm lint
```
Expected: PASS
**Step 6: Commit**
```bash
git add src/components/email/ src/app/\(dashboard\)/campaigns/new/
git commit -m "feat: Kampagnen-Wizard — Editor, RecipientPicker, SchedulePicker, 5-Schritt-Flow"
```
---
## Task 12: Abschluss — Gesamttest & Lint
**Step 1: Test-Infrastruktur starten**
```bash
docker compose -f docker-compose.test.yml up -d
```
(Mailhog 1025/8025, ClickHouse 9000, Redis 6380)
**Step 2: Alle Tests ausführen**
```bash
pnpm test
```
Expected: Alle Tests PASS
**Step 3: Lint ausführen**
```bash
pnpm lint
```
Expected: Keine Fehler
**Step 4: Build prüfen**
```bash
pnpm build
```
Expected: Kein TypeScript-Fehler
**Step 5: Final-Commit**
```bash
git add .
git commit -m "feat: Kampagnen-Funktion vollständig — CRUD, BullMQ-Versand, ClickHouse-Analytics, Frontend-Wizard"
```