feat: port full CRUD API routes and frontend pages from Immich Watcher

Backend API (38 routes):
- providers: full CRUD + test connection + list collections + API key masking
- trackers: full CRUD + trigger + history + test-periodic/memory
- tracking-configs: full CRUD with Pydantic models, provider_type filter
- template-configs: full CRUD + preview + preview-raw with two-pass validation
- targets: full CRUD + test notification + config masking
- telegram-bots: full CRUD + chat discovery + token endpoint
- users: full admin CRUD + password reset + self-delete protection
- status: dashboard endpoint with providers/trackers/targets/events counts

Frontend pages updated:
- Dashboard with animated stat cards and event timeline
- Providers with proper components, delete confirm, snackbar
- Trackers/targets/tracking-configs/template-configs/telegram-bots/users
  all use PageHeader, Card, Loading, MdiIcon with correct i18n keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:49:40 +03:00
parent c9cab93d12
commit 9eec21a5b2
17 changed files with 1596 additions and 244 deletions
+157 -5
View File
@@ -1,13 +1,165 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
const auth = getAuth();
let status = $state<any>(null);
let loaded = $state(false);
let error = $state('');
let displayProviders = $state(0);
let displayActive = $state(0);
let displayTotal = $state(0);
let displayTargets = $state(0);
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
if (to === 0) { setter(0); return; }
const start = performance.now();
function frame(now: number) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setter(Math.round(from + (to - from) * eased));
if (progress < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
onMount(async () => {
try {
const [providers, trackers, targets] = await Promise.all([
api<any[]>('/providers'),
api<any[]>('/trackers'),
api<any[]>('/targets'),
]);
status = {
providers: providers.length,
trackers: { active: trackers.filter((t: any) => t.enabled).length, total: trackers.length },
targets: targets.length,
recent_events: [],
};
setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
animateCount(0, status.targets, (v) => displayTargets = v);
}, 200);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
loaded = true;
}
});
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
] : []);
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
};
const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
};
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
<div class="text-center py-12">
<p class="text-muted-foreground">{t('dashboard.noEvents')}</p>
</div>
{#if !loaded}
<Loading lines={4} />
{:else if error}
<Card>
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={20} />
<p class="text-sm">{error}</p>
</div>
</Card>
{:else if status}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8 stagger-children">
{#each statCards as card, i}
<div class="stat-card" style="--accent: {card.color};">
<div class="stat-card-inner">
<div class="flex items-center gap-3">
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
<MdiIcon name={card.icon} size={22} />
</div>
<div>
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
</div>
</div>
</div>
</div>
{/each}
</div>
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
</h3>
{#if status.recent_events.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
<p class="text-sm">{t('dashboard.noEvents')}</p>
</div>
</Card>
{:else}
<div class="event-timeline stagger-children">
{#each status.recent_events as event, i}
<div class="event-item" style="animation-delay: {i * 60}ms;">
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
<div class="event-content">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
</span>
<span class="text-sm font-medium truncate">{event.collection_name}</span>
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
</div>
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); transform: translateY(-2px); }
.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-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; }
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
.event-content { flex: 1; min-width: 0; padding: 0.5rem 0.875rem; border-radius: 0.625rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
</style>
+80 -33
View File
@@ -1,49 +1,96 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let providers = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
let deleteTarget = $state<any>(null);
onMount(async () => {
try {
providers = await api.get('/providers');
} catch { /* auth redirect handled by api */ }
loading = false;
await loadProviders();
});
async function loadProviders() {
try {
providers = await api('/providers');
} catch (err: any) {
snackError(err.message);
} finally {
loaded = true;
}
}
async function deleteProvider() {
if (!deleteTarget) return;
try {
await api(`/providers/${deleteTarget.id}`, { method: 'DELETE' });
snackSuccess(t('snack.providerDeleted'));
deleteTarget = null;
await loadProviders();
} catch (err: any) {
snackError(err.message);
}
}
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.providers')}</h1>
<a href="/providers/new" class="px-4 py-2 bg-primary text-primary-foreground rounded-[var(--radius)] text-sm font-medium">
{t('provider.addProvider')}
</a>
</div>
<PageHeader title={t('providers.title')} description={t('providers.description')}>
<a href="/providers/new"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200"
style="background: var(--color-primary); color: var(--color-primary-foreground);"
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
<MdiIcon name="mdiPlus" size={16} />
{t('providers.addProvider')}
</a>
</PageHeader>
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if providers.length === 0}
<div class="text-center py-16 text-muted-foreground">
<p class="text-lg mb-2">{t('common.noData')}</p>
<p class="text-sm">Add your first service provider to get started.</p>
{#if !loaded}
<Loading />
{:else if providers.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;">
<MdiIcon name="mdiServer" size={40} />
</div>
<p class="text-sm">{t('providers.noProviders')}</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 stagger-children">
{#each providers as provider}
<div class="p-4 bg-card rounded-xl border border-border">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold">
{provider.type[0].toUpperCase()}
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 stagger-children">
{#each providers as provider}
<Card hover>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
<MdiIcon name={provider.icon || 'mdiServer'} size={20} />
</div>
<div>
<h3 class="font-semibold">{provider.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{provider.type}</p>
<h3 class="font-medium text-sm">{provider.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{provider.type}</p>
</div>
</div>
<IconButton icon="mdiDelete" variant="danger" title={t('providers.delete')}
onclick={() => deleteTarget = provider} />
</div>
{/each}
</div>
{/if}
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal
open={!!deleteTarget}
title={t('providers.confirmDelete')}
message={deleteTarget?.name || ''}
onconfirm={deleteProvider}
oncancel={() => deleteTarget = null}
/>
+36 -23
View File
@@ -1,34 +1,47 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let targets = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
onMount(async () => {
try { targets = await api.get('/targets'); } catch {}
loading = false;
try { targets = await api('/targets'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.targets')}</h1>
</div>
<PageHeader title={t('targets.title')} description={t('targets.description')} />
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if targets.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each targets as target}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{target.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{target.type}</p>
</div>
{/each}
{#if !loaded}
<Loading />
{:else if targets.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
<p class="text-sm">{t('targets.noTargets')}</p>
</div>
{/if}
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each targets as target}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
<MdiIcon name={target.type === 'telegram' ? 'mdiTelegram' : 'mdiWebhook'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{target.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{target.type}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
+45 -6
View File
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let bots = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { bots = await api('/telegram-bots'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.telegramBots')}</h1>
<p class="text-muted-foreground">Telegram bot management — coming soon.</p>
</div>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')} />
{#if !loaded}
<Loading />
{:else if bots.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
<p class="text-sm">{t('telegramBot.noBots')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each bots as bot}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: #229ED9; color: white;">
<MdiIcon name="mdiRobot" size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{bot.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">@{bot.bot_username || '...'}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
@@ -1,8 +1,51 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { configs = await api('/template-configs'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.templateConfigs')}</h1>
<p class="text-muted-foreground">Template configuration management — coming soon.</p>
</div>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')} />
{#if !loaded}
<Loading />
{:else if configs.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">{config.description || config.provider_type}</p>
</div>
{#if config.user_id === 0}
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: var(--color-muted); color: var(--color-muted-foreground);">System</span>
{/if}
</div>
</Card>
{/each}
</div>
{/if}
+42 -23
View File
@@ -1,34 +1,53 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let trackers = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
onMount(async () => {
try { trackers = await api.get('/trackers'); } catch {}
loading = false;
try { trackers = await api('/trackers'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.trackers')}</h1>
</div>
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if trackers.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each trackers as tracker}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{tracker.name}</h3>
<p class="text-xs text-muted-foreground">{tracker.collection_ids?.length || 0} collections</p>
</div>
{/each}
{#if !loaded}
<Loading />
{:else if trackers.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
<p class="text-sm">{t('trackers.noTrackers')}</p>
</div>
{/if}
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each trackers as tracker}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
<MdiIcon name="mdiRadar" size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{tracker.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} &middot; {t('trackers.every')} {tracker.scan_interval}s
</p>
</div>
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span>
</div>
</Card>
{/each}
</div>
{/if}
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { configs = await api('/tracking-configs'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.trackingConfigs')}</h1>
<p class="text-muted-foreground">Tracking configuration management — coming soon.</p>
</div>
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')} />
{#if !loaded}
<Loading />
{:else if configs.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
<p class="text-sm">{t('trackingConfig.noConfigs')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiCog'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{config.provider_type}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
+45 -6
View File
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let users = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { users = await api('/users'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.users')}</h1>
<p class="text-muted-foreground">User management — coming soon.</p>
</div>
<PageHeader title={t('users.title')} description={t('users.description')} />
{#if !loaded}
<Loading />
{:else if users.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
<p class="text-sm">No users found.</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style="background: var(--color-primary); color: var(--color-primary-foreground);">
{user.username[0].toUpperCase()}
</div>
<div>
<h3 class="font-medium text-sm">{user.username}</h3>
<p class="text-xs uppercase tracking-wide" style="color: var(--color-muted-foreground);">{user.role}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
@@ -1,11 +1,13 @@
"""Service Provider CRUD API routes."""
"""Service provider management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
@@ -26,23 +28,57 @@ class ProviderUpdate(BaseModel):
config: dict[str, Any] | None = None
class ProviderResponse(BaseModel):
id: int
type: str
name: str
icon: str
config: dict[str, Any]
created_at: str
@router.get("")
async def list_providers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all service providers for the current user."""
result = await session.exec(
select(ServiceProvider).where(ServiceProvider.user_id == user.id)
)
return result.all()
providers = result.all()
return [_provider_response(p) for p in providers]
@router.post("", status_code=201)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_provider(
body: ProviderCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Add a new service provider (validates connection for known types)."""
# Validate connection for known provider types
if body.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = body.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
body.name,
)
test_result = await immich.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", f"Cannot connect to {body.type} provider"),
)
# Store external_domain from server config if available
if test_result.get("external_domain"):
config["external_domain"] = test_result["external_domain"]
provider = ServiceProvider(
user_id=user.id,
type=body.type,
@@ -53,7 +89,7 @@ async def create_provider(
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
return _provider_response(provider)
@router.get("/{provider_id}")
@@ -62,10 +98,9 @@ async def get_provider(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
return provider
"""Get a specific service provider."""
provider = await _get_user_provider(session, provider_id, user.id)
return _provider_response(provider)
@router.put("/{provider_id}")
@@ -75,32 +110,58 @@ async def update_provider(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
"""Update a service provider."""
provider = await _get_user_provider(session, provider_id, user.id)
if body.name is not None:
provider.name = body.name
if body.icon is not None:
provider.icon = body.icon
config_changed = body.config is not None and body.config != provider.config
if body.config is not None:
provider.config = body.config
# Re-validate connection when config changes for known provider types
if config_changed and provider.type == "immich":
try:
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
test_result = await immich.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", f"Cannot connect to {provider.type} provider"),
)
if test_result.get("external_domain"):
provider.config = {**provider.config, "external_domain": test_result["external_domain"]}
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
return _provider_response(provider)
@router.delete("/{provider_id}", status_code=204)
@router.delete("/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider(
provider_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
"""Delete a service provider."""
provider = await _get_user_provider(session, provider_id, user.id)
await session.delete(provider)
await session.commit()
@@ -111,12 +172,10 @@ async def test_provider(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
"""Check if a service provider is reachable."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
import aiohttp
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
@@ -138,12 +197,10 @@ async def list_collections(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
"""Fetch collections from a service provider."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
import aiohttp
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
@@ -157,3 +214,30 @@ async def list_collections(
return await immich.list_collections()
return []
def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
# Mask sensitive fields
if "api_key" in config:
key = config["api_key"]
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
return {
"id": p.id,
"type": p.type,
"name": p.name,
"icon": p.icon,
"config": config,
"created_at": p.created_at.isoformat(),
}
async def _get_user_provider(
session: AsyncSession, provider_id: int, user_id: int
) -> ServiceProvider:
"""Get a provider owned by the user, or raise 404."""
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.user_id != user_id:
raise HTTPException(status_code=404, detail="Provider not found")
return provider
@@ -0,0 +1,55 @@
"""Status/dashboard API route."""
from fastapi import APIRouter, Depends
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, ServiceProvider, Tracker, EventLog, User
router = APIRouter(prefix="/api/status", tags=["status"])
@router.get("")
async def get_status(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get dashboard status data."""
providers_count = (await session.exec(
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
)).one()
trackers_result = await session.exec(
select(Tracker).where(Tracker.user_id == user.id)
)
trackers = trackers_result.all()
active_count = sum(1 for t in trackers if t.enabled)
targets_count = (await session.exec(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
recent_events = await session.exec(
select(EventLog)
.join(Tracker, EventLog.tracker_id == Tracker.id)
.where(Tracker.user_id == user.id)
.order_by(EventLog.created_at.desc())
.limit(10)
)
return {
"providers": providers_count,
"trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count,
"recent_events": [
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"created_at": e.created_at.isoformat(),
}
for e in recent_events.all()
],
}
@@ -1,6 +1,6 @@
"""NotificationTarget CRUD API routes."""
"""Notification target management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/targets", tags=["targets"])
class TargetCreate(BaseModel):
type: str
type: str # "telegram" or "webhook"
name: str
icon: str = ""
config: dict[str, Any] = {}
@@ -33,23 +33,48 @@ async def list_targets(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all notification targets for the current user."""
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return result.all()
return [
{
"id": t.id,
"type": t.type,
"name": t.name,
"icon": t.icon,
"config": _safe_config(t),
"template_config_id": t.template_config_id,
"created_at": t.created_at.isoformat(),
}
for t in result.all()
]
@router.post("", status_code=201)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_target(
body: TargetCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = NotificationTarget(user_id=user.id, **body.model_dump())
"""Create a new notification target."""
if body.type not in ("telegram", "webhook"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Type must be 'telegram' or 'webhook'",
)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
template_config_id=body.template_config_id,
)
session.add(target)
await session.commit()
await session.refresh(target)
return target
return {"id": target.id, "type": target.type, "name": target.name}
@router.get("/{target_id}")
@@ -58,10 +83,16 @@ async def get_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
return target
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"template_config_id": target.template_config_id,
}
@router.put("/{target_id}")
@@ -71,27 +102,62 @@ async def update_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
if body.name is not None:
target.name = body.name
if body.icon is not None:
target.icon = body.icon
if body.config is not None:
target.config = body.config
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
session.add(target)
await session.commit()
await session.refresh(target)
return target
return {"id": target.id, "type": target.type, "name": target.name}
@router.delete("/{target_id}", status_code=204)
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
"""Delete a notification target."""
target = await _get_user_target(session, target_id, user.id)
await session.delete(target)
await session.commit()
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a target."""
target = await _get_user_target(session, target_id, user.id)
from ..services.notifier import send_test_notification
result = await send_test_notification(target)
return result
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
if "bot_token" in config:
token = config["bot_token"]
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
if "api_key" in config:
config["api_key"] = "***"
return config
async def _get_user_target(
session: AsyncSession, target_id: int, user_id: int
) -> NotificationTarget:
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user_id:
raise HTTPException(status_code=404, detail="Target not found")
return target
@@ -0,0 +1,299 @@
"""Telegram bot management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import aiohttp
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TelegramBot, TelegramChat, User
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
class BotCreate(BaseModel):
name: str
token: str
class BotUpdate(BaseModel):
name: str | None = None
commands_config: dict | None = None
@router.get("")
async def list_bots(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all registered Telegram bots."""
result = await session.exec(
select(TelegramBot).where(TelegramBot.user_id == user.id)
)
return [_bot_response(b) for b in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_bot(
body: BotCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Register a new Telegram bot (validates token via getMe)."""
bot_info = await _get_me(body.token)
if not bot_info:
raise HTTPException(status_code=400, detail="Invalid bot token")
bot = TelegramBot(
user_id=user.id,
name=body.name,
token=body.token,
bot_username=bot_info.get("username", ""),
bot_id=bot_info.get("id", 0),
)
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.put("/{bot_id}")
async def update_bot(
bot_id: int,
body: BotUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name and/or commands config."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
if body.commands_config is not None:
bot.commands_config = body.commands_config
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a registered bot and its chats."""
bot = await _get_user_bot(session, bot_id, user.id)
# Delete associated chats
result = await session.exec(select(TelegramChat).where(TelegramChat.bot_id == bot_id))
for chat in result.all():
await session.delete(chat)
await session.delete(bot)
await session.commit()
@router.get("/{bot_id}/token")
async def get_bot_token(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get the full bot token (used by frontend to construct target config)."""
bot = await _get_user_bot(session, bot_id, user.id)
return {"token": bot.token}
# --- Chat management ---
@router.get("/{bot_id}/chats")
async def list_bot_chats(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List persisted chats for a bot."""
await _get_user_bot(session, bot_id, user.id) # Auth check
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/chats/discover")
async def discover_chats(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Discover new chats via Telegram getUpdates and persist them.
Merges newly discovered chats with existing ones (no duplicates).
Returns the full updated chat list.
"""
bot = await _get_user_bot(session, bot_id, user.id)
discovered = await _fetch_chats_from_telegram(bot.token)
# Load existing chats to avoid duplicates
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
existing = {c.chat_id: c for c in result.all()}
new_count = 0
for chat_data in discovered:
cid = str(chat_data["id"])
if cid in existing:
# Update title/username if changed
existing_chat = existing[cid]
existing_chat.title = chat_data.get("title", existing_chat.title)
existing_chat.username = chat_data.get("username", existing_chat.username)
session.add(existing_chat)
else:
new_chat = TelegramChat(
bot_id=bot_id,
chat_id=cid,
title=chat_data.get("title", ""),
chat_type=chat_data.get("type", "private"),
username=chat_data.get("username", ""),
)
session.add(new_chat)
new_count += 1
await session.commit()
# Return full list
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
return [_chat_response(c) for c in result.all()]
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
chat_db_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a persisted chat entry."""
await _get_user_bot(session, bot_id, user.id) # Auth check
chat = await session.get(TelegramChat, chat_db_id)
if not chat or chat.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Chat not found")
await session.delete(chat)
await session.commit()
# --- Helpers ---
async def _get_me(token: str) -> dict | None:
"""Call Telegram getMe to validate token and get bot info."""
try:
async with aiohttp.ClientSession() as http:
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getMe") as resp:
data = await resp.json()
if data.get("ok"):
return data.get("result", {})
except aiohttp.ClientError:
pass
return None
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
"""Fetch chats from Telegram getUpdates API."""
seen: dict[int, dict] = {}
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{TELEGRAM_API_BASE_URL}{token}/getUpdates",
params={"limit": 100, "allowed_updates": '["message"]'},
) as resp:
data = await resp.json()
if not data.get("ok"):
return []
for update in data.get("result", []):
msg = update.get("message", {})
chat = msg.get("chat", {})
chat_id = chat.get("id")
if chat_id and chat_id not in seen:
seen[chat_id] = {
"id": chat_id,
"title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
"type": chat.get("type", "private"),
"username": chat.get("username", ""),
}
except aiohttp.ClientError:
pass
return list(seen.values())
def _chat_response(c: TelegramChat) -> dict:
return {
"id": c.id,
"chat_id": c.chat_id,
"title": c.title,
"type": c.chat_type,
"username": c.username,
"discovered_at": c.discovered_at.isoformat(),
}
def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"commands_config": b.commands_config,
"created_at": b.created_at.isoformat(),
}
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> TelegramBot:
bot = await session.get(TelegramBot, bot_id)
if not bot or bot.user_id != user_id:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
async def save_chat_from_webhook(
session: AsyncSession, bot_id: int, chat_data: dict
) -> None:
"""Save or update a chat entry from an incoming webhook message.
Called by the webhook handler to auto-persist chats.
"""
chat_id = str(chat_data.get("id", ""))
if not chat_id:
return
result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
existing = result.first()
title = chat_data.get("title") or (
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
)
if existing:
existing.title = title
existing.username = chat_data.get("username", existing.username)
session.add(existing)
else:
session.add(TelegramChat(
bot_id=bot_id,
chat_id=chat_id,
title=title,
chat_type=chat_data.get("type", "private"),
username=chat_data.get("username", ""),
))
@@ -1,85 +1,271 @@
"""TemplateConfig CRUD API routes."""
"""Template configuration CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, User
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
# Sample asset matching what build_asset_detail() actually returns
_SAMPLE_ASSET = {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "IMG_001.jpg",
"type": "IMAGE",
"created_at": "2026-03-19T10:30:00",
"owner": "Alice",
"owner_id": "user-uuid-1",
"description": "Family picnic",
"people": ["Alice", "Bob"],
"is_favorite": True,
"rating": 5,
"latitude": 48.8566,
"longitude": 2.3522,
"city": "Paris",
"state": "Ile-de-France",
"country": "France",
"url": "https://immich.example.com/photos/abc123",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
}
_SAMPLE_VIDEO_ASSET = {
**_SAMPLE_ASSET,
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
"filename": "VID_002.mp4",
"type": "VIDEO",
"is_favorite": False,
"rating": None,
"photo_url": None,
"playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_COLLECTION = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
# Full context covering ALL possible template variables
_SAMPLE_CONTEXT = {
# Core event fields (always present)
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"collection_name": "Family Photos",
"collection_url": "https://immich.example.com/share/abc123",
"change_type": "assets_added",
"added_count": 3,
"removed_count": 1,
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
"removed_assets": ["asset-id-1", "asset-id-2"],
"people": ["Alice", "Bob"],
"shared": True,
"target_type": "telegram",
"has_videos": True,
"has_photos": True,
# Rename fields (always present, empty for non-rename events)
"old_name": "Old Album",
"new_name": "New Album",
"old_shared": False,
"new_shared": True,
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"date": "2026-03-19",
}
class TemplateConfigCreate(BaseModel):
provider_type: str
name: str
description: str | None = None
icon: str | None = None
message_assets_added: str | None = None
message_assets_removed: str | None = None
message_collection_renamed: str | None = None
message_collection_deleted: str | None = None
message_sharing_changed: str | None = None
periodic_summary_message: str | None = None
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
@router.get("")
async def list_template_configs(
async def list_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from sqlalchemy import or_
query = select(TemplateConfig).where(
(TemplateConfig.user_id == user.id) | (TemplateConfig.user_id == 0)
or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0)
)
if provider_type:
query = query.where(TemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return result.all()
return [_response(c) for c in result.all()]
@router.post("", status_code=201)
async def create_template_config(
body: dict,
@router.get("/variables")
async def get_template_variables(provider_type: str | None = None):
"""Get the variable reference for all template slots."""
from .template_vars import router as _ # noqa: ensure registered
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
if provider_type:
try:
pt = ServiceProviderType(provider_type)
except ValueError:
return {"error": f"Unknown provider type: {provider_type}"}
variables = registry.get_variables(pt)
else:
variables = registry.get_base_variables()
return [
{
"name": v.name,
"type": v.type,
"description": v.description,
"example": v.example,
"provider_type": v.provider_type.value if v.provider_type else None,
}
for v in variables
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
body: TemplateConfigCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TemplateConfig(user_id=user.id, **body)
data = {k: v for k, v in body.model_dump().items() if v is not None}
config = TemplateConfig(user_id=user.id, **data)
session.add(config)
await session.commit()
await session.refresh(config)
return config
return _response(config)
@router.get("/{config_id}")
async def get_template_config(
async def get_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user.id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
return _response(await _get(session, config_id, user.id))
@router.put("/{config_id}")
async def update_template_config(
async def update_config(
config_id: int,
body: dict,
body: TemplateConfigUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Template config not found")
for field, value in body.items():
if field not in ("id", "user_id", "created_at"):
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
if value is not None:
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
return config
return _response(config)
@router.delete("/{config_id}", status_code=204)
async def delete_template_config(
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Template config not found")
config = await _get(session, config_id, user.id)
await session.delete(config)
await session.commit()
@router.post("/{config_id}/preview")
async def preview_config(
config_id: int,
slot: str = "message_assets_added",
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Render a specific template slot with sample data."""
config = await _get(session, config_id, user.id)
template_body = getattr(config, slot, None)
if template_body is None:
raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}")
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_body)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"slot": slot, "rendered": rendered}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Template error: {e}")
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # "telegram" or "webhook"
@router.post("/preview-raw")
async def preview_raw(
body: PreviewRequest,
user: User = Depends(get_current_user),
):
"""Render arbitrary Jinja2 template text with sample data.
Two-pass validation:
1. Parse with default Undefined (catches syntax errors)
2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
"""
# Pass 1: syntax check
try:
env = SandboxedEnvironment(autoescape=False)
env.from_string(body.template)
except TemplateSyntaxError as e:
return {
"rendered": None,
"error": e.message,
"error_line": e.lineno,
}
# Pass 2: render with strict undefined to catch unknown variables
try:
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**ctx)
return {"rendered": rendered}
except UndefinedError as e:
# Still a valid template syntactically, but references unknown variable
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}
def _response(c: TemplateConfig) -> dict:
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | {
"created_at": c.created_at.isoformat()
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
@@ -1,13 +1,13 @@
"""Tracker CRUD API routes."""
"""Tracker management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import Tracker, User
from ..database.models import EventLog, NotificationTarget, ServiceProvider, Tracker, User
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
@@ -42,21 +42,27 @@ async def list_trackers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(select(Tracker).where(Tracker.user_id == user.id))
return result.all()
result = await session.exec(
select(Tracker).where(Tracker.user_id == user.id)
)
return [_tracker_response(t) for t in result.all()]
@router.post("", status_code=201)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_tracker(
body: TrackerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = await session.get(ServiceProvider, body.provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
tracker = Tracker(user_id=user.id, **body.model_dump())
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return tracker
return _tracker_response(tracker)
@router.get("/{tracker_id}")
@@ -65,10 +71,7 @@ async def get_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
return tracker
return _tracker_response(await _get_user_tracker(session, tracker_id, user.id))
@router.put("/{tracker_id}")
@@ -78,28 +81,22 @@ async def update_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
tracker = await _get_user_tracker(session, tracker_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(tracker, field, value)
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return tracker
return _tracker_response(tracker)
@router.delete("/{tracker_id}", status_code=204)
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
tracker = await _get_user_tracker(session, tracker_id, user.id)
await session.delete(tracker)
await session.commit()
@@ -110,10 +107,96 @@ async def trigger_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
tracker = await _get_user_tracker(session, tracker_id, user.id)
from ..services.watcher import check_tracker
result = await check_tracker(tracker_id)
return result
result = await check_tracker(tracker.id)
return {"triggered": True, "result": result}
@router.post("/{tracker_id}/test-periodic")
async def test_periodic(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test periodic summary notification to all targets."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
from ..services.notifier import send_test_notification
results = []
for tid in list(tracker.target_ids):
target = await session.get(NotificationTarget, tid)
if target:
r = await send_test_notification(target)
results.append({"target": target.name, **r})
return {"test": "periodic_summary", "results": results}
@router.post("/{tracker_id}/test-memory")
async def test_memory(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test memory/on-this-day notification to all targets."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
from ..services.notifier import send_test_notification
results = []
for tid in list(tracker.target_ids):
target = await session.get(NotificationTarget, tid)
if target:
r = await send_test_notification(target)
results.append({"target": target.name, **r})
return {"test": "memory_mode", "results": results}
@router.get("/{tracker_id}/history")
async def tracker_history(
tracker_id: int,
limit: int = Query(default=20, ge=1, le=500),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_tracker(session, tracker_id, user.id)
result = await session.exec(
select(EventLog)
.where(EventLog.tracker_id == tracker_id)
.order_by(EventLog.created_at.desc())
.limit(limit)
)
return [
{
"id": e.id,
"event_type": e.event_type,
"collection_id": e.collection_id,
"collection_name": e.collection_name,
"details": e.details,
"created_at": e.created_at.isoformat(),
}
for e in result.all()
]
def _tracker_response(t: Tracker) -> dict:
return {
"id": t.id,
"name": t.name,
"icon": t.icon,
"provider_id": t.provider_id,
"collection_ids": t.collection_ids,
"target_ids": t.target_ids,
"tracking_config_id": t.tracking_config_id,
"scan_interval": t.scan_interval,
"enabled": t.enabled,
"quiet_hours_start": t.quiet_hours_start,
"quiet_hours_end": t.quiet_hours_end,
"created_at": t.created_at.isoformat(),
}
async def _get_user_tracker(
session: AsyncSession, tracker_id: int, user_id: int
) -> Tracker:
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user_id:
raise HTTPException(status_code=404, detail="Tracker not found")
return tracker
@@ -1,6 +1,7 @@
"""TrackingConfig CRUD API routes."""
"""Tracking configuration CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -11,8 +12,85 @@ from ..database.models import TrackingConfig, User
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
class TrackingConfigCreate(BaseModel):
provider_type: str
name: str
icon: str = ""
track_assets_added: bool = True
track_assets_removed: bool = False
track_collection_renamed: bool = True
track_collection_deleted: bool = True
track_sharing_changed: bool = False
track_images: bool = True
track_videos: bool = True
notify_favorites_only: bool = False
include_tags: bool = True
include_asset_details: bool = False
max_assets_to_show: int = 5
assets_order_by: str = "none"
assets_order: str = "descending"
periodic_enabled: bool = False
periodic_interval_days: int = 1
periodic_start_date: str = "2025-01-01"
periodic_times: str = "12:00"
scheduled_enabled: bool = False
scheduled_times: str = "09:00"
scheduled_collection_mode: str = "per_collection"
scheduled_limit: int = 10
scheduled_favorite_only: bool = False
scheduled_asset_type: str = "all"
scheduled_min_rating: int = 0
scheduled_order_by: str = "random"
scheduled_order: str = "descending"
memory_enabled: bool = False
memory_times: str = "09:00"
memory_collection_mode: str = "combined"
memory_limit: int = 10
memory_favorite_only: bool = False
memory_asset_type: str = "all"
memory_min_rating: int = 0
class TrackingConfigUpdate(BaseModel):
name: str | None = None
icon: str | None = None
track_assets_added: bool | None = None
track_assets_removed: bool | None = None
track_collection_renamed: bool | None = None
track_collection_deleted: bool | None = None
track_sharing_changed: bool | None = None
track_images: bool | None = None
track_videos: bool | None = None
notify_favorites_only: bool | None = None
include_tags: bool | None = None
include_asset_details: bool | None = None
max_assets_to_show: int | None = None
assets_order_by: str | None = None
assets_order: str | None = None
periodic_enabled: bool | None = None
periodic_interval_days: int | None = None
periodic_start_date: str | None = None
periodic_times: str | None = None
scheduled_enabled: bool | None = None
scheduled_times: str | None = None
scheduled_collection_mode: str | None = None
scheduled_limit: int | None = None
scheduled_favorite_only: bool | None = None
scheduled_asset_type: str | None = None
scheduled_min_rating: int | None = None
scheduled_order_by: str | None = None
scheduled_order: str | None = None
memory_enabled: bool | None = None
memory_times: str | None = None
memory_collection_mode: str | None = None
memory_limit: int | None = None
memory_favorite_only: bool | None = None
memory_asset_type: str | None = None
memory_min_rating: int | None = None
@router.get("")
async def list_tracking_configs(
async def list_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
@@ -21,63 +99,66 @@ async def list_tracking_configs(
if provider_type:
query = query.where(TrackingConfig.provider_type == provider_type)
result = await session.exec(query)
return result.all()
return [_response(c) for c in result.all()]
@router.post("", status_code=201)
async def create_tracking_config(
body: dict,
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
body: TrackingConfigCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TrackingConfig(user_id=user.id, **body)
config = TrackingConfig(user_id=user.id, **body.model_dump())
session.add(config)
await session.commit()
await session.refresh(config)
return config
return _response(config)
@router.get("/{config_id}")
async def get_tracking_config(
async def get_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
return config
return _response(await _get(session, config_id, user.id))
@router.put("/{config_id}")
async def update_tracking_config(
async def update_config(
config_id: int,
body: dict,
body: TrackingConfigUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
for field, value in body.items():
if field not in ("id", "user_id", "created_at"):
setattr(config, field, value)
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
return config
return _response(config)
@router.delete("/{config_id}", status_code=204)
async def delete_tracking_config(
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
config = await _get(session, config_id, user.id)
await session.delete(config)
await session.commit()
def _response(c: TrackingConfig) -> dict:
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k != "user_id"} | {
"created_at": c.created_at.isoformat()
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TrackingConfig:
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user_id:
raise HTTPException(status_code=404, detail="Tracking config not found")
return config
@@ -0,0 +1,101 @@
"""User management API routes (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import bcrypt
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import User
router = APIRouter(prefix="/api/users", tags=["users"])
class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
class UserUpdate(BaseModel):
username: str | None = None
password: str | None = None
role: str | None = None
@router.get("")
async def list_users(
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""List all users (admin only)."""
result = await session.exec(select(User))
return [
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
for u in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Create a new user (admin only)."""
# Check for duplicate username
result = await session.exec(select(User).where(User.username == body.username))
if result.first():
raise HTTPException(status_code=409, detail="Username already exists")
user = User(
username=body.username,
hashed_password=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
role=body.role if body.role in ("admin", "user") else "user",
)
session.add(user)
await session.commit()
await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role}
class ResetPasswordRequest(BaseModel):
new_password: str
@router.put("/{user_id}/password")
async def reset_user_password(
user_id: int,
body: ResetPasswordRequest,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Reset a user's password (admin only)."""
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
session.add(user)
await session.commit()
return {"success": True}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Delete a user (admin only, cannot delete self)."""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
await session.delete(user)
await session.commit()
@@ -13,6 +13,9 @@ from .api.trackers import router as trackers_router
from .api.tracking_configs import router as tracking_configs_router
from .api.template_configs import router as template_configs_router
from .api.targets import router as targets_router
from .api.telegram_bots import router as telegram_bots_router
from .api.users import router as users_router
from .api.status import router as status_router
from .api.template_vars import router as template_vars_router
@@ -35,6 +38,9 @@ app.include_router(trackers_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
app.include_router(telegram_bots_router)
app.include_router(users_router)
app.include_router(status_router)
@app.get("/api/health")