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

53 KiB

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

// 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

pnpm test src/lib/result.test.ts

Expected: FAIL — "Cannot find module './result'"

Step 3: Result Pattern implementieren

// 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

pnpm test src/lib/result.test.ts

Expected: PASS

Step 5: Failing Test schreiben — Crypto

// 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

pnpm test src/lib/crypto.test.ts

Expected: FAIL

Step 7: Crypto implementieren

// 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

pnpm test src/lib/crypto.test.ts

Expected: PASS

Step 9: Globale Typen anlegen

// 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

// 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

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

// 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

pnpm test src/server/db/tenant.test.ts

Expected: FAIL

Step 3: DB-Client anlegen

// 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

// 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

pnpm test src/server/db/tenant.test.ts

Expected: PASS

Step 6: ClickHouse-Client anlegen

// 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

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

-- 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

-- 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

# 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

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

// 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

pnpm test src/server/db/campaigns.test.ts

Expected: FAIL

Step 3: Repository implementieren

// 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

pnpm test src/server/db/campaigns.test.ts

Expected: PASS

Step 5: Commit

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

// 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

pnpm test src/queues/email-send.test.ts

Expected: FAIL

Step 3: Queue implementieren

// 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

pnpm test src/queues/email-send.test.ts

Expected: PASS

Step 5: Commit

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

// 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

pnpm test src/server/suppression/check.test.ts

Step 3: Suppression-Check implementieren

// 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

pnpm test src/server/suppression/check.test.ts

Expected: PASS

Step 5: Failing Test — SMTP-Client

// 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

pnpm test src/server/smtp/client.test.ts

Step 7: SMTP-Client implementieren

// 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

pnpm test src/server/smtp/client.test.ts

Expected: PASS

Step 9: Commit

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

// 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

pnpm test src/queues/email-send.worker.test.ts

Step 3: Worker implementieren

// 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

pnpm test src/queues/email-send.worker.test.ts

Expected: PASS

Step 5: Commit

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

// 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]

// 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

// 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

// 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

pnpm test && pnpm lint

Expected: PASS

Step 6: Commit

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

// 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

pnpm test src/analytics/campaigns.test.ts

Step 3: Analytics Query implementieren

// 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

// 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

pnpm test src/analytics/campaigns.test.ts

Expected: PASS

Step 6: Commit

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

// 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

// 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

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

// 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

// 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

// 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

// 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

pnpm test && pnpm lint

Expected: PASS

Step 6: Commit

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

docker compose -f docker-compose.test.yml up -d

(Mailhog 1025/8025, ClickHouse 9000, Redis 6380)

Step 2: Alle Tests ausführen

pnpm test

Expected: Alle Tests PASS

Step 3: Lint ausführen

pnpm lint

Expected: Keine Fehler

Step 4: Build prüfen

pnpm build

Expected: Kein TypeScript-Fehler

Step 5: Final-Commit

git add .
git commit -m "feat: Kampagnen-Funktion vollständig — CRUD, BullMQ-Versand, ClickHouse-Analytics, Frontend-Wizard"