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:
122
src/server/db/campaigns.test.ts
Normal file
122
src/server/db/campaigns.test.ts
Normal 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
115
src/server/db/campaigns.ts
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user