feat: broadcast notification target + UX improvements
Add broadcast target type that fans out notifications to multiple child targets. Dispatch expands broadcast into children in load_link_data() — dispatcher stays unaware. Children can be toggled on/off via disabled_child_ids in config. Also: dashboard provider card smaller font for names, scroll-to-form on target edit, broadcast nav tab with counter, flag_modified fix for JSON column updates, CLAUDE.md nav tree docs.
This commit is contained in:
@@ -34,4 +34,5 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
||||
- Components use `getDescriptor(type)` and render dynamically from the descriptor
|
||||
- Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'`
|
||||
- Template variable helpers: ALL provider types must have entries in `get_template_variables()`
|
||||
9. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`.
|
||||
9. **Nav tree & entity types** — when adding a new entity type (target type, bot type, etc.), update the sidebar nav in `frontend/src/routes/+layout.svelte`. Target types need: `{ href: '/targets?type={type}', key: 'nav.target{PascalName}', icon: '...', countKey: 'targets_{type}' }` in the `children` array under `nav.targets`, plus i18n keys `nav.target{PascalName}` in both locale files. Also add the counter entry `targets_{type}: targets.filter(t => t.type === '{type}').length` to the `counts` derived block in `+layout.svelte`.
|
||||
10. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`.
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"targetSlack": "Slack",
|
||||
"targetNtfy": "ntfy",
|
||||
"targetMatrix": "Matrix",
|
||||
"targetBroadcast": "Broadcast",
|
||||
"automation": "Automation",
|
||||
"actions": "Actions"
|
||||
},
|
||||
@@ -256,6 +257,11 @@
|
||||
"descSlack": "Slack channel webhooks for notifications",
|
||||
"descNtfy": "ntfy push notification topics",
|
||||
"descMatrix": "Matrix room destinations for notifications",
|
||||
"descBroadcast": "Send to multiple targets at once",
|
||||
"childTargets": "target(s)",
|
||||
"selectChildTargets": "Select child targets",
|
||||
"noChildTargets": "No child targets configured.",
|
||||
"noChildTargetsAvailable": "Create other targets first, then add them here.",
|
||||
"addTarget": "Add Target",
|
||||
"cancel": "Cancel",
|
||||
"type": "Type",
|
||||
@@ -814,6 +820,8 @@
|
||||
"syntaxError": "Syntax error",
|
||||
"undefinedVar": "Unknown variable",
|
||||
"line": "line",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"add": "Add",
|
||||
"filterByName": "Filter by name...",
|
||||
"allTypes": "All types",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"targetSlack": "Slack",
|
||||
"targetNtfy": "ntfy",
|
||||
"targetMatrix": "Matrix",
|
||||
"targetBroadcast": "Рассылка",
|
||||
"automation": "Автоматизация",
|
||||
"actions": "Действия"
|
||||
},
|
||||
@@ -256,6 +257,11 @@
|
||||
"descSlack": "Вебхуки каналов Slack для уведомлений",
|
||||
"descNtfy": "Топики ntfy для push-уведомлений",
|
||||
"descMatrix": "Комнаты Matrix для доставки уведомлений",
|
||||
"descBroadcast": "Отправка сразу в несколько целей",
|
||||
"childTargets": "цель(ей)",
|
||||
"selectChildTargets": "Выберите дочерние цели",
|
||||
"noChildTargets": "Дочерние цели не настроены.",
|
||||
"noChildTargetsAvailable": "Сначала создайте другие цели, затем добавьте их сюда.",
|
||||
"addTarget": "Добавить получателя",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип",
|
||||
@@ -814,6 +820,8 @@
|
||||
"syntaxError": "Ошибка синтаксиса",
|
||||
"undefinedVar": "Неизвестная переменная",
|
||||
"line": "строка",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"add": "Добавить",
|
||||
"filterByName": "Фильтр по имени...",
|
||||
"allTypes": "Все типы",
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface NotificationTarget {
|
||||
chat_name?: string;
|
||||
receiver_count: number;
|
||||
receivers: TargetReceiver[];
|
||||
/** Broadcast targets only: resolved child target summaries. */
|
||||
child_targets?: { id: number; name: string; type: string; icon: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
targets_slack: targets.filter(t => t.type === 'slack').length,
|
||||
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
|
||||
targets_matrix: targets.filter(t => t.type === 'matrix').length,
|
||||
targets_broadcast: targets.filter(t => t.type === 'broadcast').length,
|
||||
} as Record<string, number>;
|
||||
});
|
||||
|
||||
@@ -169,6 +170,7 @@
|
||||
{ href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' },
|
||||
{ href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' },
|
||||
{ href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
|
||||
{ href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -362,6 +362,7 @@
|
||||
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
||||
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
||||
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
||||
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
|
||||
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
||||
.event-timeline { display: flex; flex-direction: column; }
|
||||
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
@@ -70,15 +70,17 @@
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||
type TargetType = typeof ALL_TYPES[number];
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
||||
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
|
||||
broadcast: 'mdiBullhorn',
|
||||
};
|
||||
const TYPE_DESC_KEYS: Record<string, string> = {
|
||||
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
|
||||
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
|
||||
broadcast: 'targets.descBroadcast',
|
||||
};
|
||||
|
||||
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
|
||||
@@ -120,6 +122,8 @@
|
||||
matrix_bot_id: 0,
|
||||
// Email
|
||||
email_bot_id: 0,
|
||||
// Broadcast
|
||||
child_target_ids: [] as number[],
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
@@ -128,6 +132,12 @@
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||
let formEl: HTMLElement;
|
||||
|
||||
async function scrollToForm() {
|
||||
await tick();
|
||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ── Receiver inline form state ──
|
||||
|
||||
@@ -185,6 +195,7 @@
|
||||
editing = null;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
scrollToForm();
|
||||
}
|
||||
|
||||
async function edit(tgt: NotificationTarget) {
|
||||
@@ -207,10 +218,13 @@
|
||||
email_bot_id: c.email_bot_id || 0,
|
||||
// matrix
|
||||
matrix_bot_id: c.matrix_bot_id || 0,
|
||||
// broadcast
|
||||
child_target_ids: c.child_target_ids || [],
|
||||
};
|
||||
editing = tgt.id;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
scrollToForm();
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
@@ -245,6 +259,8 @@
|
||||
config = { email_bot_id: form.email_bot_id };
|
||||
} else if (formType === 'matrix') {
|
||||
config = { matrix_bot_id: form.matrix_bot_id };
|
||||
} else if (formType === 'broadcast') {
|
||||
config = { child_target_ids: form.child_target_ids };
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
@@ -367,6 +383,21 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function toggleBroadcastChild(targetId: number, childId: number) {
|
||||
const tgt = allTargets.find(t => t.id === targetId);
|
||||
if (!tgt) return;
|
||||
const disabled = new Set<number>(tgt.config?.disabled_child_ids || []);
|
||||
if (disabled.has(childId)) disabled.delete(childId);
|
||||
else disabled.add(childId);
|
||||
try {
|
||||
await api(`/targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
||||
});
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function testReceiver(targetId: number, receiverId: number) {
|
||||
receiverTesting = { ...receiverTesting, [receiverId]: true };
|
||||
try {
|
||||
@@ -392,6 +423,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div bind:this={formEl}></div>
|
||||
<TargetForm
|
||||
bind:form
|
||||
bind:formType
|
||||
@@ -404,6 +436,7 @@
|
||||
telegramBotCount={telegramBots.length}
|
||||
emailBotCount={emailBots.length}
|
||||
matrixBotCount={matrixBots.length}
|
||||
broadcastChildItems={allTargets.filter(t => t.type !== 'broadcast' && t.id !== editing).map(t => ({ value: t.id, label: t.name, icon: t.icon || TYPE_ICONS[t.type] || 'mdiTarget', desc: t.type }))}
|
||||
{editing}
|
||||
{submitting}
|
||||
{error}
|
||||
@@ -439,7 +472,11 @@
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||
<p class="font-medium">{target.name}</p>
|
||||
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
||||
{#if (target.receivers || []).length > 0}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>{/if}
|
||||
{#if target.type === 'broadcast' && target.child_targets?.length}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
||||
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>
|
||||
{/if}
|
||||
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,6 +506,7 @@
|
||||
ontestReceiver={testReceiver}
|
||||
onloadBotChats={loadReceiverBotChats}
|
||||
onchangeReceiverForm={(f) => receiverForm = f}
|
||||
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||
/>
|
||||
</Card>
|
||||
{/each}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
ontestReceiver: (targetId: number, receiverId: number) => void;
|
||||
onloadBotChats: (botId: number) => void;
|
||||
onchangeReceiverForm: (form: Record<string, any>) => void;
|
||||
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -44,10 +45,37 @@
|
||||
ontestReceiver,
|
||||
onloadBotChats,
|
||||
onchangeReceiverForm,
|
||||
ontoggleBroadcastChild,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
{#if target.type === 'broadcast'}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.childTargets')}</p>
|
||||
</div>
|
||||
{@const disabledIds = new Set(target.config?.disabled_child_ids || [])}
|
||||
{#if target.child_targets?.length}
|
||||
{#each target.child_targets as child (child.id)}
|
||||
{@const isDisabled = disabledIds.has(child.id)}
|
||||
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={isDisabled}>
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={child.icon || typeIcons[child.type] || 'mdiTarget'} size={14} />
|
||||
<span class="text-sm">{child.name}</span>
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{child.type}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={isDisabled ? 'mdiToggleSwitchOff' : 'mdiToggleSwitch'}
|
||||
title={isDisabled ? t('common.enable') : t('common.disable')}
|
||||
onclick={() => ontoggleBroadcastChild?.(target.id, child.id)}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] italic">{t('targets.noChildTargets')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
|
||||
</div>
|
||||
@@ -153,4 +181,5 @@
|
||||
{t('targets.addReceiver')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
@@ -28,6 +29,7 @@
|
||||
auth_token: string;
|
||||
matrix_bot_id: number;
|
||||
email_bot_id: number;
|
||||
child_target_ids: number[];
|
||||
};
|
||||
formType: string;
|
||||
activeType: string | null;
|
||||
@@ -36,6 +38,7 @@
|
||||
emailBotItems: EntityItem[];
|
||||
matrixBotItems: EntityItem[];
|
||||
chatActionItems: GridItem[];
|
||||
broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[];
|
||||
telegramBotCount: number;
|
||||
emailBotCount: number;
|
||||
matrixBotCount: number;
|
||||
@@ -56,6 +59,7 @@
|
||||
emailBotItems,
|
||||
matrixBotItems,
|
||||
chatActionItems,
|
||||
broadcastChildItems = [],
|
||||
telegramBotCount,
|
||||
emailBotCount,
|
||||
matrixBotCount,
|
||||
@@ -160,6 +164,20 @@
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if formType === 'broadcast'}
|
||||
{@const childIds = (form.child_target_ids || []).map(String)}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</label>
|
||||
<MultiEntitySelect
|
||||
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
|
||||
values={childIds}
|
||||
onchange={(vals) => form.child_target_ids = vals.map(Number)}
|
||||
placeholder={t('targets.selectChildTargets')}
|
||||
/>
|
||||
{#if broadcastChildItems?.length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('targets.noChildTargetsAvailable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formType === 'telegram'}
|
||||
|
||||
@@ -111,8 +111,11 @@ async def list_targets(
|
||||
if lang:
|
||||
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
||||
|
||||
# Build lookup for broadcast child target resolution
|
||||
target_map = {t.id: t for t in targets}
|
||||
|
||||
return [
|
||||
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages)
|
||||
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages, target_map)
|
||||
for t in targets
|
||||
]
|
||||
|
||||
@@ -124,15 +127,24 @@ async def create_target(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new notification target."""
|
||||
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix")
|
||||
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix", "broadcast")
|
||||
if body.type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Type must be one of: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
# Extract delivery fields from config — they go into a TargetReceiver
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
|
||||
# Broadcast-specific validation
|
||||
if body.type == "broadcast":
|
||||
child_ids = body.config.get("child_target_ids", [])
|
||||
if not isinstance(child_ids, list):
|
||||
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
|
||||
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=None)
|
||||
clean_config = {"child_target_ids": child_ids}
|
||||
receiver_cfg: dict[str, Any] = {}
|
||||
else:
|
||||
# Extract delivery fields from config — they go into a TargetReceiver
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
|
||||
|
||||
target = NotificationTarget(
|
||||
user_id=user.id,
|
||||
@@ -190,34 +202,46 @@ async def update_target(
|
||||
|
||||
# If config is being updated, extract any delivery fields
|
||||
if "config" in updates and updates["config"] is not None:
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
|
||||
updates["config"] = clean_config
|
||||
if target.type == "broadcast":
|
||||
child_ids = updates["config"].get("child_target_ids", [])
|
||||
if not isinstance(child_ids, list):
|
||||
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
|
||||
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=target.id)
|
||||
disabled_ids = updates["config"].get("disabled_child_ids", [])
|
||||
updates["config"] = {"child_target_ids": child_ids, "disabled_child_ids": disabled_ids}
|
||||
else:
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
|
||||
updates["config"] = clean_config
|
||||
|
||||
# Update or create receiver if delivery fields were present
|
||||
if receiver_cfg:
|
||||
key = _receiver_key(target.type, receiver_cfg)
|
||||
existing_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.receiver_key == key,
|
||||
# Update or create receiver if delivery fields were present
|
||||
if receiver_cfg:
|
||||
key = _receiver_key(target.type, receiver_cfg)
|
||||
existing_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.receiver_key == key,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_recv = existing_result.first()
|
||||
if existing_recv:
|
||||
existing_recv.config = receiver_cfg
|
||||
session.add(existing_recv)
|
||||
else:
|
||||
receiver = TargetReceiver(
|
||||
target_id=target.id,
|
||||
name=target.name,
|
||||
config=receiver_cfg,
|
||||
receiver_key=key,
|
||||
enabled=True,
|
||||
)
|
||||
session.add(receiver)
|
||||
existing_recv = existing_result.first()
|
||||
if existing_recv:
|
||||
existing_recv.config = receiver_cfg
|
||||
session.add(existing_recv)
|
||||
else:
|
||||
receiver = TargetReceiver(
|
||||
target_id=target.id,
|
||||
name=target.name,
|
||||
config=receiver_cfg,
|
||||
receiver_key=key,
|
||||
enabled=True,
|
||||
)
|
||||
session.add(receiver)
|
||||
|
||||
for field_name, value in updates.items():
|
||||
setattr(target, field_name, value)
|
||||
# Force SQLAlchemy to detect JSON column change
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
if "config" in updates:
|
||||
flag_modified(target, "config")
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
@@ -257,11 +281,37 @@ async def test_target(
|
||||
return result
|
||||
|
||||
|
||||
async def _validate_broadcast_children(
|
||||
session: AsyncSession,
|
||||
child_ids: list[int],
|
||||
user_id: int,
|
||||
*,
|
||||
exclude_target_id: int | None = None,
|
||||
) -> None:
|
||||
"""Validate broadcast child target IDs.
|
||||
|
||||
- All IDs must exist and belong to the user
|
||||
- No broadcast targets allowed as children (prevents circular refs)
|
||||
- Cannot reference itself
|
||||
"""
|
||||
if not child_ids:
|
||||
return
|
||||
if exclude_target_id and exclude_target_id in child_ids:
|
||||
raise HTTPException(status_code=400, detail="A broadcast target cannot include itself")
|
||||
for child_id in child_ids:
|
||||
child = await session.get(NotificationTarget, child_id)
|
||||
if not child or child.user_id != user_id:
|
||||
raise HTTPException(status_code=400, detail=f"Child target {child_id} not found")
|
||||
if child.type == "broadcast":
|
||||
raise HTTPException(status_code=400, detail="A broadcast target cannot include another broadcast")
|
||||
|
||||
|
||||
def _target_response(
|
||||
target: NotificationTarget,
|
||||
chat_names: dict[str, str] | None = None,
|
||||
receivers: list[TargetReceiver] | None = None,
|
||||
chat_languages: dict[str, str] | None = None,
|
||||
target_map: dict[int, NotificationTarget] | None = None,
|
||||
) -> dict:
|
||||
recv_list = receivers or []
|
||||
resp = {
|
||||
@@ -295,6 +345,14 @@ def _target_response(
|
||||
recv_resp["chat_name"] = chat_names[key]
|
||||
if chat_languages and key in chat_languages:
|
||||
recv_resp["language_code"] = chat_languages[key]
|
||||
# Attach child target summaries for broadcast targets
|
||||
if target.type == "broadcast" and target_map:
|
||||
child_ids = target.config.get("child_target_ids", [])
|
||||
resp["child_targets"] = [
|
||||
{"id": cid, "name": target_map[cid].name, "type": target_map[cid].type, "icon": target_map[cid].icon}
|
||||
for cid in child_ids if cid in target_map
|
||||
]
|
||||
resp["receiver_count"] = len(resp["child_targets"])
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,88 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
async def _resolve_target(
|
||||
session: AsyncSession,
|
||||
target: NotificationTarget,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve a single target into dispatch-ready data (config + receivers + credentials).
|
||||
|
||||
Returns a dict with target_type, target_config, and receivers.
|
||||
Does NOT include tracking_config or template_slots — those come from the tracker link.
|
||||
"""
|
||||
# Load receivers as typed Receiver objects
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.enabled == True,
|
||||
)
|
||||
)
|
||||
recv_rows = recv_result.all()
|
||||
|
||||
# For Telegram targets, resolve locale from TelegramChat
|
||||
chat_locale_map: dict[str, str] = {}
|
||||
if target.type == "telegram":
|
||||
bot_id = target.config.get("bot_id")
|
||||
if bot_id:
|
||||
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||
if chat_ids:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id.in_(chat_ids),
|
||||
)
|
||||
)
|
||||
for chat in chat_result.all():
|
||||
resolved = (
|
||||
getattr(chat, 'language_override', '') or
|
||||
getattr(chat, 'language_code', '') or ''
|
||||
)
|
||||
if resolved:
|
||||
chat_locale_map[chat.chat_id] = resolved[:2].lower()
|
||||
|
||||
receivers: list[Receiver] = []
|
||||
for r in recv_rows:
|
||||
explicit_locale = getattr(r, 'locale', '') or ''
|
||||
locale = explicit_locale
|
||||
if not locale and target.type == "telegram":
|
||||
chat_id = str(r.config.get("chat_id", ""))
|
||||
locale = chat_locale_map.get(chat_id, "")
|
||||
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject chat_action for Telegram targets
|
||||
if hasattr(target, 'chat_action') and target.chat_action:
|
||||
target_config["chat_action"] = target.chat_action
|
||||
# Inject bot credentials for bot-backed target types
|
||||
if target.type == "email":
|
||||
email_bot_id = target.config.get("email_bot_id")
|
||||
if email_bot_id:
|
||||
email_bot = await session.get(EmailBot, email_bot_id)
|
||||
if email_bot:
|
||||
target_config["smtp"] = {
|
||||
"host": email_bot.smtp_host,
|
||||
"port": email_bot.smtp_port,
|
||||
"username": email_bot.smtp_username,
|
||||
"password": email_bot.smtp_password,
|
||||
"from_address": email_bot.email,
|
||||
"from_name": email_bot.name,
|
||||
"use_tls": email_bot.smtp_use_tls,
|
||||
}
|
||||
elif target.type == "matrix":
|
||||
matrix_bot_id = target.config.get("matrix_bot_id")
|
||||
if matrix_bot_id:
|
||||
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
||||
if matrix_bot:
|
||||
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
||||
target_config["access_token"] = matrix_bot.access_token
|
||||
|
||||
return {
|
||||
"target_type": target.type,
|
||||
"target_config": target_config,
|
||||
"receivers": receivers,
|
||||
}
|
||||
|
||||
|
||||
async def load_link_data(
|
||||
session: AsyncSession,
|
||||
tracker_id: int,
|
||||
@@ -127,45 +209,7 @@ async def load_link_data(
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Load receivers as typed Receiver objects
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.enabled == True,
|
||||
)
|
||||
)
|
||||
recv_rows = recv_result.all()
|
||||
|
||||
# For Telegram targets, resolve locale from TelegramChat
|
||||
chat_locale_map: dict[str, str] = {}
|
||||
if target.type == "telegram":
|
||||
bot_id = target.config.get("bot_id")
|
||||
if bot_id:
|
||||
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||
if chat_ids:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id.in_(chat_ids),
|
||||
)
|
||||
)
|
||||
for chat in chat_result.all():
|
||||
resolved = (
|
||||
getattr(chat, 'language_override', '') or
|
||||
getattr(chat, 'language_code', '') or ''
|
||||
)
|
||||
if resolved:
|
||||
chat_locale_map[chat.chat_id] = resolved[:2].lower()
|
||||
|
||||
receivers: list[Receiver] = []
|
||||
for r in recv_rows:
|
||||
explicit_locale = getattr(r, 'locale', '') or ''
|
||||
locale = explicit_locale
|
||||
if not locale and target.type == "telegram":
|
||||
chat_id = str(r.config.get("chat_id", ""))
|
||||
locale = chat_locale_map.get(chat_id, "")
|
||||
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||
|
||||
# Load tracking config and template slots (shared across broadcast children)
|
||||
tracking_config = None
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
@@ -184,37 +228,29 @@ async def load_link_data(
|
||||
raw_slots.setdefault(event_key, {})[s.locale] = s.template
|
||||
template_slots = raw_slots
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject chat_action for Telegram targets
|
||||
if hasattr(target, 'chat_action') and target.chat_action:
|
||||
target_config["chat_action"] = target.chat_action
|
||||
# Inject bot credentials for bot-backed target types
|
||||
if target.type == "email":
|
||||
email_bot_id = target.config.get("email_bot_id")
|
||||
if email_bot_id:
|
||||
email_bot = await session.get(EmailBot, email_bot_id)
|
||||
if email_bot:
|
||||
target_config["smtp"] = {
|
||||
"host": email_bot.smtp_host,
|
||||
"port": email_bot.smtp_port,
|
||||
"username": email_bot.smtp_username,
|
||||
"password": email_bot.smtp_password,
|
||||
"from_address": email_bot.email,
|
||||
"from_name": email_bot.name,
|
||||
"use_tls": email_bot.smtp_use_tls,
|
||||
}
|
||||
elif target.type == "matrix":
|
||||
matrix_bot_id = target.config.get("matrix_bot_id")
|
||||
if matrix_bot_id:
|
||||
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
||||
if matrix_bot:
|
||||
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
||||
target_config["access_token"] = matrix_bot.access_token
|
||||
# Broadcast target: expand into child targets
|
||||
if target.type == "broadcast":
|
||||
child_ids = target.config.get("child_target_ids", [])
|
||||
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||
for child_id in child_ids:
|
||||
if child_id in disabled_ids:
|
||||
continue
|
||||
child_target = await session.get(NotificationTarget, child_id)
|
||||
if not child_target or child_target.type == "broadcast":
|
||||
continue
|
||||
resolved = await _resolve_target(session, child_target)
|
||||
link_data.append({
|
||||
**resolved,
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
"template_slots": template_slots,
|
||||
})
|
||||
continue
|
||||
|
||||
# Regular target
|
||||
resolved = await _resolve_target(session, target)
|
||||
link_data.append({
|
||||
"target_type": target.type,
|
||||
"target_config": target_config,
|
||||
"receivers": receivers,
|
||||
**resolved,
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
"template_slots": template_slots,
|
||||
|
||||
@@ -309,11 +309,35 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message."""
|
||||
"""Send a simple test message. For broadcast targets, fans out to all children."""
|
||||
if target.type == "broadcast":
|
||||
return await _send_broadcast_test(target, locale)
|
||||
message = _get_test_message(locale, target.type)
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
|
||||
"""Send test notifications to all child targets of a broadcast target."""
|
||||
child_ids = target.config.get("child_target_ids", [])
|
||||
if not child_ids:
|
||||
return {"success": False, "error": "No child targets configured"}
|
||||
|
||||
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||
engine = get_engine()
|
||||
results: list[dict] = []
|
||||
async with AsyncSession(engine) as session:
|
||||
for child_id in child_ids:
|
||||
if child_id in disabled_ids:
|
||||
continue
|
||||
child = await session.get(NotificationTarget, child_id)
|
||||
if not child or child.type == "broadcast":
|
||||
continue
|
||||
message = _get_test_message(locale, child.type)
|
||||
results.append(await send_to_target(child, message))
|
||||
|
||||
return _aggregate(results)
|
||||
|
||||
|
||||
async def send_test_template_notification(
|
||||
target: NotificationTarget, slot: str, template_str: str
|
||||
) -> dict:
|
||||
|
||||
@@ -15,7 +15,7 @@ fi
|
||||
# Start backend
|
||||
NOTIFY_BRIDGE_DATA_DIR=./test-data \
|
||||
NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \
|
||||
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || command -v py 2>/dev/null)
|
||||
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "py -3.13")
|
||||
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
||||
--host 0.0.0.0 --port 8420 > /dev/null 2>&1 &
|
||||
|
||||
|
||||
Reference in New Issue
Block a user