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