feat: Campaign Repository — CRUD mit Tenant-Isolation und Result-Pattern

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 09:52:55 +00:00
parent c4d3c348c1
commit 4245a1fbde
2 changed files with 237 additions and 0 deletions

View File

@@ -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: '<p>Hi</p>',
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: '<p>Hi</p>',
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: '<p>Hi</p>', plainBody: 'Hi',
})
if (result.ok) expect(result.data.htmlBody).toBe('<p>Hi</p>')
})
})
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)
})
})

115
src/server/db/campaigns.ts Normal file
View File

@@ -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<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, (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<Result<Campaign>> {
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<Result<Campaign[]>> {
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<Result<Campaign>> {
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<Result<Campaign>> {
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)))
}
}