From 4245a1fbde90fc014d7ec216e48963f9d7facbf9 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Fri, 17 Apr 2026 09:52:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Campaign=20Repository=20=E2=80=94=20CRU?= =?UTF-8?q?D=20mit=20Tenant-Isolation=20und=20Result-Pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert createCampaign, getCampaign, listCampaigns, updateCampaign und updateCampaignStatus mit withTenant-Context, snake_case→camelCase Mapping und Result-Pattern. 10 Unit-Tests, alle grün. Co-Authored-By: Claude Sonnet 4.6 --- src/server/db/campaigns.test.ts | 122 ++++++++++++++++++++++++++++++++ src/server/db/campaigns.ts | 115 ++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/server/db/campaigns.test.ts create mode 100644 src/server/db/campaigns.ts diff --git a/src/server/db/campaigns.test.ts b/src/server/db/campaigns.test.ts new file mode 100644 index 0000000..74c475e --- /dev/null +++ b/src/server/db/campaigns.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockClient = vi.hoisted(() => ({ + execute: vi.fn(), + query: vi.fn(), +})) + +vi.mock('./tenant', () => ({ + withTenant: vi.fn((_, fn) => fn(mockClient)), +})) + +import { + createCampaign, + getCampaign, + updateCampaign, + listCampaigns, + updateCampaignStatus, +} from './campaigns' + +const mockRow = { + id: 'uuid-1', + name: 'Test', + subject: 'Betreff', + html_body: '

Hi

', + plain_body: 'Hi', + status: 'draft', + scheduled_at: null, + cron_expression: null, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), +} + +describe('createCampaign', () => { + beforeEach(() => vi.clearAllMocks()) + + it('gibt erstellte Kampagne zurück', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + const result = await createCampaign('tenant1', { + name: 'Test', + subject: 'Betreff', + htmlBody: '

Hi

', + plainBody: 'Hi', + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.name).toBe('Test') + }) + + it('mapped html_body → htmlBody korrekt', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + const result = await createCampaign('tenant1', { + name: 'Test', subject: 'Betreff', htmlBody: '

Hi

', plainBody: 'Hi', + }) + if (result.ok) expect(result.data.htmlBody).toBe('

Hi

') + }) +}) + +describe('getCampaign', () => { + beforeEach(() => vi.clearAllMocks()) + + it('gibt Kampagne zurück wenn gefunden', async () => { + mockClient.query.mockResolvedValueOnce([mockRow]) + const result = await getCampaign('tenant1', 'uuid-1') + expect(result.ok).toBe(true) + }) + + it('gibt err zurück wenn nicht gefunden', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await getCampaign('tenant1', 'uuid-1') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('nicht gefunden') + }) +}) + +describe('listCampaigns', () => { + beforeEach(() => vi.clearAllMocks()) + + it('gibt leeres Array zurück wenn keine Kampagnen', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await listCampaigns('tenant1') + expect(result.ok).toBe(true) + if (result.ok) expect(result.data).toEqual([]) + }) + + it('gibt mehrere Kampagnen zurück', async () => { + mockClient.query.mockResolvedValueOnce([mockRow, { ...mockRow, id: 'uuid-2' }]) + const result = await listCampaigns('tenant1') + if (result.ok) expect(result.data).toHaveLength(2) + }) +}) + +describe('updateCampaign', () => { + beforeEach(() => vi.clearAllMocks()) + + it('aktualisiert nur übergebene Felder', async () => { + mockClient.query.mockResolvedValueOnce([{ ...mockRow, name: 'Neu' }]) + const result = await updateCampaign('tenant1', 'uuid-1', { name: 'Neu' }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.name).toBe('Neu') + }) + + it('gibt err zurück wenn keine Felder übergeben', async () => { + const result = await updateCampaign('tenant1', 'uuid-1', {}) + expect(result.ok).toBe(false) + }) +}) + +describe('updateCampaignStatus', () => { + beforeEach(() => vi.clearAllMocks()) + + it('setzt Status auf scheduled', async () => { + mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'scheduled' }]) + const result = await updateCampaignStatus('tenant1', 'uuid-1', 'scheduled') + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.status).toBe('scheduled') + }) + + it('gibt err zurück wenn Kampagne nicht gefunden', async () => { + mockClient.query.mockResolvedValueOnce([]) + const result = await updateCampaignStatus('tenant1', 'uuid-1', 'sent') + expect(result.ok).toBe(false) + }) +}) diff --git a/src/server/db/campaigns.ts b/src/server/db/campaigns.ts new file mode 100644 index 0000000..67bdfac --- /dev/null +++ b/src/server/db/campaigns.ts @@ -0,0 +1,115 @@ +import { withTenant } from './tenant' +import { ok, err, type Result } from '../../lib/result' +import type { Campaign, CampaignStatus } from '../../types' +import type { CreateCampaignInput, UpdateCampaignInput } from '../../lib/validation' + +function rowToCampaign(row: Record): 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> { + try { + const rows = await withTenant(tenantId, (client) => + client.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> { + try { + const rows = await withTenant(tenantId, (client) => + client.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> { + try { + const rows = await withTenant(tenantId, (client) => + client.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: UpdateCampaignInput +): Promise> { + 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')) + + try { + values.push(id) + const rows = await withTenant(tenantId, (client) => + client.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> { + try { + const rows = await withTenant(tenantId, (client) => + client.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))) + } +}