feat(broadcast): enhanced ListView + dashboard widget + AI tools

Closes the M7/M9/M10 plan items in one pass since they share patterns.

ListView (M7)
- 4 stats cards at the top: versendet YTD, Ø Öffnungsrate, Ø Klickrate,
  Entwürfe. Same layout pattern as invoices for consistency.
- Status filter chips with live counts per status.
- Search across name + subject.
- Row now shows open-rate per-campaign when available.
- Settings gear in the header matches the invoices polish.

Dashboard widget (M10)
- BroadcastsWidget.svelte: 2x stats (sent YTD + avg open rate), next
  scheduled link, last sent link with open-rate badge. Empty state
  nudges toward creating a first campaign.
- Registered as 'broadcasts' in WIDGET_REGISTRY and the component map.
- Medium default size, no requiredBackend (reads from Dexie only;
  stats are mirrored from the last DetailView poll so no server
  round-trip for the widget).

AI tools (M9)
- 3 tools added to @mana/shared-ai's AI_TOOL_CATALOG:
  - create_campaign_draft (propose) — generates HTML body from a
    topic, lands as a draft; user picks audience + sends via UI
  - list_campaigns (auto) — id/name/subject/status/recipients
  - get_campaign_stats (auto) — rates as 0..1 floats
- broadcast/tools.ts: execute handlers with an HTML→CampaignContent
  shim (stores both html and a minimal Tiptap JSON placeholder so
  ListView renders without the editor having to remount). stripHtml
  helper derives plaintext.
- Registered in data/tools/init.ts after library.

Suggest-style tools (suggest_subject_lines) deliberately omitted —
they're pure generative and don't need an executor. The LLM can
produce subject ideas without a tool call.

Verified:
- pnpm check: 0 broadcast errors (4 pre-existing errors in articles
  module from parallel work, not mine)
- shared-ai test suite: 44/44 green (function-schema roundtrips the
  expanded catalog cleanly)
- mana-ai drift guard: 41/41 green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 15:27:59 +02:00
parent c5a76d726c
commit 75832faef7
8 changed files with 623 additions and 11 deletions

View file

@ -34,6 +34,7 @@ import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
@ -64,4 +65,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
'news-unread': NewsUnreadWidget,
'body-stats': BodyStatsWidget,
'invoices-open': InvoicesOpenWidget,
broadcasts: BroadcastsWidget,
};

View file

@ -43,6 +43,7 @@ import { wetterTools } from '$lib/modules/wetter/tools';
import { quizTools } from '$lib/modules/quiz/tools';
import { invoicesTools } from '$lib/modules/invoices/tools';
import { libraryTools } from '$lib/modules/library/tools';
import { broadcastTools } from '$lib/modules/broadcast/tools';
let initialized = false;
@ -87,5 +88,6 @@ export function initTools(): void {
registerTools(quizTools);
registerTools(invoicesTools);
registerTools(libraryTools);
registerTools(broadcastTools);
initialized = true;
}

View file

@ -1,19 +1,30 @@
<!--
Broadcast — ListView (M2)
Real campaign list + working "+ Neue Kampagne" entry point. Stats
cards, filter chips, and search land in M7.
Broadcast — ListView (M7)
Stats cards + status filter chips + search + row navigation.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllCampaigns } from './queries';
import { useAllCampaigns, computeStats, searchCampaigns, formatRate } from './queries';
import { STATUS_LABELS, STATUS_COLORS } from './constants';
import type { CampaignStatus } from './types';
const campaigns$ = useAllCampaigns();
const campaigns = $derived(campaigns$.value ?? []);
let activeStatus = $state<CampaignStatus | 'all'>('all');
let searchQuery = $state('');
const currentYear = new Date().getFullYear();
const stats = $derived(computeStats(campaigns, currentYear));
const filtered = $derived.by(() => {
let out = campaigns;
if (activeStatus !== 'all') out = out.filter((c) => c.status === activeStatus);
if (searchQuery.trim()) out = searchCampaigns(out, searchQuery.trim());
return out;
});
function openCampaign(id: string, status: string) {
// Drafts open in the compose flow for editing; everything else
// opens the read-only DetailView with stats.
if (status === 'draft') goto(`/broadcasts/${id}/edit`);
else goto(`/broadcasts/${id}`);
}
@ -29,9 +40,73 @@
<h1>Broadcasts</h1>
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
</div>
<button class="btn-primary" type="button" onclick={onNewCampaign}>+ Neue Kampagne</button>
<div class="head-actions">
<button
class="btn-icon"
type="button"
title="Einstellungen"
aria-label="Einstellungen"
onclick={() => goto('/broadcasts/settings')}
>
</button>
<button class="btn-primary" type="button" onclick={onNewCampaign}> + Neue Kampagne </button>
</div>
</header>
{#if campaigns.length > 0}
<section class="stats">
<div class="stat">
<div class="stat-label">Versendet {currentYear}</div>
<div class="stat-value">{stats.sentThisYear}</div>
<div class="stat-sub">Kampagnen</div>
</div>
<div class="stat">
<div class="stat-label">Ø Öffnungsrate</div>
<div class="stat-value">{formatRate(stats.avgOpenRate)}</div>
<div class="stat-sub">über alle Kampagnen</div>
</div>
<div class="stat">
<div class="stat-label">Ø Klickrate</div>
<div class="stat-value">{formatRate(stats.avgClickRate)}</div>
<div class="stat-sub">über alle Kampagnen</div>
</div>
<div class="stat">
<div class="stat-label">Entwürfe</div>
<div class="stat-value">{stats.totalByStatus.draft}</div>
<div class="stat-sub">in Arbeit</div>
</div>
</section>
<section class="filters">
<div class="chips">
<button
class="chip"
class:active={activeStatus === 'all'}
onclick={() => (activeStatus = 'all')}
>
Alle <span class="count">{campaigns.length}</span>
</button>
{#each ['draft', 'scheduled', 'sending', 'sent', 'cancelled'] as status (status)}
<button
class="chip"
class:active={activeStatus === status}
onclick={() => (activeStatus = status as CampaignStatus)}
>
{STATUS_LABELS[status as CampaignStatus].de}
<span class="count">{stats.totalByStatus[status as CampaignStatus]}</span>
</button>
{/each}
</div>
<input
class="search"
type="search"
placeholder="Suchen (Name oder Betreff)"
bind:value={searchQuery}
/>
</section>
{/if}
{#if campaigns.length === 0}
<div class="empty">
<div class="empty-icon">📣</div>
@ -42,9 +117,17 @@
</p>
<button class="btn-primary" onclick={onNewCampaign}>Erste Kampagne</button>
</div>
{:else if filtered.length === 0}
<div class="empty">
<p>Keine Kampagnen gefunden.</p>
</div>
{:else}
<ul class="list" role="list">
{#each campaigns as campaign (campaign.id)}
{#each filtered as campaign (campaign.id)}
{@const openRate =
campaign.stats && campaign.stats.sent > 0
? campaign.stats.opened / campaign.stats.sent
: null}
<li>
<button class="row" onclick={() => openCampaign(campaign.id, campaign.status)}>
<span class="subject">
@ -56,6 +139,13 @@
<span class="recipient-count">
{campaign.audience?.estimatedCount ?? 0} Empfänger
</span>
{#if campaign.status === 'sent' && openRate !== null}
<span class="open-rate" title="Öffnungsrate">
{formatRate(openRate)} 👀
</span>
{:else}
<span class="open-rate empty-rate"></span>
{/if}
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
<span class="dot"></span>
{STATUS_LABELS[campaign.status].de}
@ -70,7 +160,7 @@
<style>
.broadcast-shell {
padding: 1.5rem;
max-width: 1000px;
max-width: 1100px;
margin: 0 auto;
}
@ -79,6 +169,8 @@
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.head h1 {
@ -93,6 +185,12 @@
font-size: 0.9rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-primary {
background: #6366f1;
color: white;
@ -104,6 +202,102 @@
font-size: 0.95rem;
}
.btn-icon {
width: 2.25rem;
height: 2.25rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
cursor: pointer;
font-size: 1rem;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.stat {
padding: 0.9rem 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
}
.stat-label {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stat-value {
margin-top: 0.25rem;
font-size: 1.3rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-sub {
margin-top: 0.15rem;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.filters {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.chips {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 999px;
cursor: pointer;
font-size: 0.85rem;
}
.chip.active {
background: #4338ca;
color: white;
border-color: #4338ca;
}
.chip .count {
background: rgba(0, 0, 0, 0.08);
padding: 0 0.4rem;
border-radius: 999px;
font-size: 0.75rem;
}
.chip.active .count {
background: rgba(255, 255, 255, 0.2);
}
.search {
padding: 0.45rem 0.75rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.9rem;
min-width: 240px;
}
.empty {
text-align: center;
padding: 4rem 1rem;
@ -138,7 +332,7 @@
.row {
display: grid;
grid-template-columns: 1fr auto 9rem;
grid-template-columns: 1fr auto 6rem 9rem;
gap: 1rem;
align-items: center;
width: 100%;
@ -179,6 +373,16 @@
color: var(--color-text-muted, #64748b);
}
.open-rate {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
.empty-rate {
color: var(--color-text-muted, #94a3b8);
}
.status {
display: inline-flex;
align-items: center;

View file

@ -29,6 +29,8 @@ export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte
export { renderEmailHtml } from './render/email-html';
export { renderPlainText } from './render/plain-text';
export { broadcastTools } from './tools';
export {
STATUS_LABELS,
STATUS_COLORS,

View file

@ -0,0 +1,180 @@
/**
* Broadcast Tools LLM-accessible operations.
*
* Schema definitions live in @mana/shared-ai's AI_TOOL_CATALOG; this
* file wires execute fns to the local store + query layer.
*
* Mission example:
* "Schreib jeden Freitag einen Newsletter-Entwurf aus meinen Notizen
* der Woche" auto-list_notes + propose create_campaign_draft.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { campaignTable } from './collections';
import { decryptRecords } from '$lib/data/crypto';
import { broadcastCampaignsStore } from './stores/campaigns.svelte';
import { toCampaign, formatRate } from './queries';
import type { LocalCampaign, CampaignStatus, CampaignContent } from './types';
/**
* Pragmatic HTML Tiptap-JSON shim. The planner produces flat HTML
* (the tool schema explicitly limits which tags are allowed), and
* Tiptap's Editor accepts HTML at init time via setContent(html).
* We persist a minimal valid Tiptap doc that the editor will replace
* on first open via its `content: html` init path.
*
* Storing both html and tiptap means the ListView + preview render
* without the editor having to remount.
*/
function htmlToCampaignContent(html: string): CampaignContent {
return {
// Tiptap-JSON placeholder — editor re-parses from html on open.
// Valid Tiptap doc structure so runtime doesn't choke on null.
tiptap: { type: 'doc', content: [{ type: 'paragraph' }] },
html,
plainText: stripHtml(html),
};
}
/** Crude but adequate: remove tags + collapse whitespace. */
function stripHtml(html: string): string {
return html
.replace(/<\/(p|div|h[1-6]|li|br)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
async function listDecryptedCampaigns(): Promise<ReturnType<typeof toCampaign>[]> {
const rows = await campaignTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
return decrypted.map(toCampaign);
}
export const broadcastTools: ModuleTool[] = [
{
name: 'create_campaign_draft',
module: 'broadcast',
description:
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body.',
parameters: [
{ name: 'name', type: 'string', description: 'Arbeitstitel', required: true },
{ name: 'subject', type: 'string', description: 'E-Mail-Betreff', required: true },
{ name: 'preheader', type: 'string', description: 'Preheader', required: false },
{
name: 'htmlContent',
type: 'string',
description: 'HTML-Body (p, h1-3, ul, ol, li, a, strong, em, br)',
required: true,
},
],
async execute(params) {
const name = String(params.name ?? '').trim() || 'Neue Kampagne';
const subject = String(params.subject ?? '').trim();
if (!subject) return { success: false, message: 'Betreff fehlt.' };
const htmlContent = String(params.htmlContent ?? '');
if (!htmlContent.trim()) return { success: false, message: 'Inhalt fehlt.' };
const id = await broadcastCampaignsStore.createCampaign({
name,
subject,
preheader: (params.preheader as string | undefined) || null,
content: htmlToCampaignContent(htmlContent),
});
return {
success: true,
data: { id, name, subject },
message: `Entwurf „${name}" angelegt. Empfänger in der UI wählen, dann versenden.`,
};
},
},
{
name: 'list_campaigns',
module: 'broadcast',
description: 'Listet Kampagnen (id, name, subject, status, Empfängerzahl, sentAt).',
parameters: [
{
name: 'status',
type: 'string',
description: 'Filter auf Status',
required: false,
enum: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'],
},
{ name: 'limit', type: 'number', description: 'Maximale Anzahl', required: false },
],
async execute(params) {
const all = await listDecryptedCampaigns();
const status = params.status as CampaignStatus | undefined;
const filtered = status ? all.filter((c) => c.status === status) : all;
const limit = Number(params.limit ?? 20);
const slice = filtered.slice(0, Math.max(1, limit));
return {
success: true,
data: slice.map((c) => ({
id: c.id,
name: c.name,
subject: c.subject,
status: c.status,
recipients: c.audience?.estimatedCount ?? 0,
sentAt: c.sentAt,
})),
message: `${slice.length} Kampagne${slice.length === 1 ? '' : 'n'}${status ? ` im Status ${status}` : ''}.`,
};
},
},
{
name: 'get_campaign_stats',
module: 'broadcast',
description: 'Gibt Raten zu einer Kampagne zurück: Öffnungs-, Klick-, Bounce- und Abmelderate.',
parameters: [{ name: 'campaignId', type: 'string', description: 'ID', required: true }],
async execute(params) {
const id = String(params.campaignId ?? '').trim();
if (!id) return { success: false, message: 'campaignId fehlt.' };
const all = await listDecryptedCampaigns();
const campaign = all.find((c) => c.id === id);
if (!campaign) return { success: false, message: `Kampagne ${id} nicht gefunden.` };
const s = campaign.stats;
if (!s || s.sent === 0) {
return {
success: true,
data: {
campaignId: id,
name: campaign.name,
status: campaign.status,
stats: null,
},
message: `Kampagne „${campaign.name}" hat noch keine Sendestatistik.`,
};
}
const openRate = s.opened / s.sent;
const clickRate = s.clicked / s.sent;
const bounceRate = s.bounced / s.sent;
const unsubRate = s.unsubscribed / s.sent;
return {
success: true,
data: {
campaignId: id,
name: campaign.name,
status: campaign.status,
stats: {
totalRecipients: s.totalRecipients,
sent: s.sent,
openRate,
clickRate,
bounceRate,
unsubscribeRate: unsubRate,
},
},
message: `${campaign.name}": ${s.sent} versendet, ${formatRate(openRate)} geöffnet, ${formatRate(clickRate)} geklickt.`,
};
},
},
];

View file

@ -0,0 +1,140 @@
<script lang="ts">
/**
* BroadcastsWidget — YTD Counts + letzte versendete Kampagne +
* nächste geplante.
*
* Rein lokal aus Dexie; keine Server-Rundreise (Stats im liveQuery
* sind aus dem letzten DetailView-Poll heraus gespiegelt).
*/
import { liveQuery } from 'dexie';
import { campaignTable } from '$lib/modules/broadcast/collections';
import { decryptRecords } from '$lib/data/crypto';
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcast/queries';
import type { Campaign, LocalCampaign } from '$lib/modules/broadcast/types';
let campaigns = $state<Campaign[]>([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const rows = await campaignTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
return decrypted.map(toCampaign);
}).subscribe({
next: (result) => {
campaigns = result;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const currentYear = new Date().getFullYear();
const stats = $derived(computeStats(campaigns, currentYear));
/** Latest sent campaign — top of mind for "how did my last one do?". */
const lastSent = $derived(
campaigns
.filter((c) => c.status === 'sent')
.sort((a, b) => (b.sentAt ?? '').localeCompare(a.sentAt ?? ''))[0]
);
/** Next scheduled — "what's about to go out". */
const nextScheduled = $derived(
campaigns
.filter((c) => c.status === 'scheduled' && c.scheduledAt)
.sort((a, b) => (a.scheduledAt ?? '').localeCompare(b.scheduledAt ?? ''))[0]
);
const lastOpenRate = $derived(
lastSent?.stats && lastSent.stats.sent > 0 ? lastSent.stats.opened / lastSent.stats.sent : null
);
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span aria-hidden="true">📣</span>
Broadcasts
</h3>
<a href="/broadcasts" class="text-xs text-muted-foreground hover:text-foreground"> Alle → </a>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(2) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if campaigns.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Noch keine Kampagnen.</p>
<a
href="/broadcasts/new"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Erste Kampagne
</a>
</div>
{:else}
<div class="mb-3 grid grid-cols-2 gap-2">
<div class="rounded-lg bg-surface-hover p-2.5">
<div class="text-xs text-muted-foreground">Versendet {currentYear}</div>
<div class="text-lg font-semibold tabular-nums">{stats.sentThisYear}</div>
</div>
<div class="rounded-lg bg-surface-hover p-2.5">
<div class="text-xs text-muted-foreground">Ø Öffnung</div>
<div class="text-lg font-semibold tabular-nums">
{formatRate(stats.avgOpenRate)}
</div>
</div>
</div>
{#if nextScheduled}
<a
href="/broadcasts/{nextScheduled.id}"
class="mb-2 block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">Als nächstes</div>
<div class="truncate text-sm font-medium">{nextScheduled.name}</div>
</div>
<div class="ml-2 text-xs text-muted-foreground">
{new Date(nextScheduled.scheduledAt ?? '').toLocaleDateString()}
</div>
</div>
</a>
{/if}
{#if lastSent}
<a
href="/broadcasts/{lastSent.id}"
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">Zuletzt versendet</div>
<div class="truncate text-sm font-medium">{lastSent.name}</div>
</div>
{#if lastOpenRate !== null}
<div class="ml-2 text-sm font-medium tabular-nums">
{formatRate(lastOpenRate)} 👀
</div>
{/if}
</div>
</a>
{/if}
{#if !lastSent && !nextScheduled}
<p class="py-4 text-center text-sm text-muted-foreground">
{stats.totalByStatus.draft} Entwurf{stats.totalByStatus.draft === 1 ? '' : 'e'} in Arbeit.
</p>
{/if}
{/if}
</div>

View file

@ -33,7 +33,8 @@ export type WidgetType =
| 'period' // Period: current phase + days until next period
| 'news-unread' // News: latest unread curated articles
| 'body-stats' // Body: latest weight + active workout summary
| 'invoices-open'; // Invoices: open/overdue totals + oldest overdue
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
| 'broadcasts'; // Broadcast: YTD counts + last sent + next scheduled
/**
* Widget size - maps to CSS Grid columns
@ -371,6 +372,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultSize: 'medium',
allowMultiple: false,
},
{
type: 'broadcasts',
nameKey: 'dashboard.widgets.broadcasts.title',
descriptionKey: 'dashboard.widgets.broadcasts.description',
icon: '📣',
defaultSize: 'medium',
allowMultiple: false,
},
];
/**

View file

@ -1323,6 +1323,79 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
},
],
},
// ── Broadcast (Newsletter) ───────────────────────────────
{
name: 'create_campaign_draft',
module: 'broadcast',
description:
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body. Empfaengerliste bleibt leer — der Nutzer waehlt sie in der UI. Gibt die ID zurueck.',
defaultPolicy: 'propose',
parameters: [
{
name: 'name',
type: 'string',
description: 'Interner Arbeitstitel der Kampagne',
required: true,
},
{
name: 'subject',
type: 'string',
description: 'E-Mail-Betreff (was im Posteingang steht)',
required: true,
},
{
name: 'preheader',
type: 'string',
description: 'Vorschau-Text neben dem Betreff in Gmail',
required: false,
},
{
name: 'htmlContent',
type: 'string',
description:
'Body als HTML. Erlaubte Tags: p, h1, h2, h3, ul, ol, li, a, strong, em, br. Links verwenden href="https://…".',
required: true,
},
],
},
{
name: 'list_campaigns',
module: 'broadcast',
description:
'Listet Kampagnen (id, name, subject, status, Empfaengerzahl, sentAt) — optional nach Status gefiltert.',
defaultPolicy: 'auto',
parameters: [
{
name: 'status',
type: 'string',
description: 'Nur diesen Status zeigen',
required: false,
enum: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard 20)',
required: false,
},
],
},
{
name: 'get_campaign_stats',
module: 'broadcast',
description:
'Gibt Kennzahlen zu einer Kampagne zurueck: Oeffnungsrate, Klickrate, Bounce-Rate, Abmelderate (jeweils 0..1).',
defaultPolicy: 'auto',
parameters: [
{
name: 'campaignId',
type: 'string',
description: 'ID der Kampagne (aus list_campaigns)',
required: true,
},
],
},
];
// ═══════════════════════════════════════════════════════════════