1921 lines
53 KiB
Markdown
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"
|
|
```
|