fix: comprehensive API/UI review — 26 bug fixes and improvements

Backend:
- Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs
- Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data
- Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified)
- Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint)
- Fix API key leak: only attach x-api-key header for internal provider URLs
- Validate config ownership in tracker_targets create/update
- Fix _response() double-emit of created_at in template/tracking configs
- Add per-target-link test endpoints (test, test-periodic, test-memory)

Frontend:
- Fix orphaned provider on test exception in providers/new
- Add submitting guard + disabled state to targets save button
- Move test buttons from tracker card to per-target-link rows
- Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations)
- i18n for dashboard timeAgo and event type badges (EN + RU)
- Add required attribute to chat select dropdown in targets
- Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono
- Standardize empty states with centered icon + text across all 6 list pages
- Add stagger-children animation class to all list containers
- Fix slide transition duration consistency (200ms everywhere)
- Standardize border-radius to rounded-md across all form inputs
- Fix providers/new page structure (h2 + mb-8 spacing)
- Fix tracker card action row overflow (flex-wrap justify-end)
- JinjaEditor dark mode reactivity (recreate editor on theme change)
- Add aria-labels to mobile nav items
- Make ConfirmModal confirm button label/icon configurable
- Remove double error reporting on providers page
- Add telegram bot edit functionality (name editing via PUT)
- i18n for External Domain label on provider forms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 14:26:20 +03:00
parent 9eec21a5b2
commit 91e5cd58e9
24 changed files with 3514 additions and 375 deletions
@@ -60,6 +60,16 @@ _SAMPLE_CONTEXT = {
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"collection_name": "Family Photos",
"collection_url": "https://immich.example.com/share/abc123",
"event_type": "assets_added",
"timestamp": "2026-03-19T10:30:00+00:00",
"service_name": "Immich",
"service_type": "immich",
# Immich aliases (always present alongside collection_*)
"album_name": "Family Photos",
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"album_url": "https://immich.example.com/share/abc123",
"old_album_name": "Old Album",
"new_album_name": "New Album",
"change_type": "assets_added",
"added_count": 3,
"removed_count": 1,
@@ -118,31 +128,109 @@ async def list_configs(
@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
async def get_template_variables():
"""Get template variable reference grouped by slot.
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()
Returns a dict keyed by template slot name, each containing:
- description: what the slot is for
- variables: dict of variable_name -> description
- asset_fields: dict of field_name -> description (for slots with assets)
- album_fields: dict of field_name -> description (for slots with albums)
"""
# Core event variables available in all event templates
event_vars = {
"collection_id": "Collection ID (UUID)",
"collection_name": "Collection name",
"collection_url": "Public share URL (empty if not shared)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"people": "Detected people names (list, use {{ people | join(', ') }})",
"shared": "Whether collection is shared (boolean)",
"target_type": "Target type: 'telegram' or 'webhook'",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
# Immich aliases
"album_name": "Alias for collection_name",
"album_id": "Alias for collection_id",
"album_url": "Alias for collection_url",
}
rename_vars = {
**event_vars,
"old_name": "Previous name (rename events)",
"new_name": "New name (rename events)",
}
sharing_vars = {
**event_vars,
"old_shared": "Was shared before change (boolean)",
"new_shared": "Is shared after change (boolean)",
}
asset_fields = {
"id": "Asset ID (UUID)",
"filename": "Original filename",
"type": "IMAGE or VIDEO",
"created_at": "Creation date/time (ISO 8601)",
"owner": "Owner display name",
"description": "User or EXIF description",
"people": "People detected in this asset (list)",
"is_favorite": "Whether asset is favorited (boolean)",
"rating": "Star rating (1-5 or null)",
"city": "City name",
"state": "State/region name",
"country": "Country name",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
"playback_url": "Video playback URL (videos only, if shared)",
}
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
}
scheduled_vars = {
"date": "Current date string",
"target_type": "Target type: 'telegram' or 'webhook'",
}
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
]
return {
"message_assets_added": {
"description": "Notification when new assets are added to a collection",
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
"asset_fields": asset_fields,
},
"message_assets_removed": {
"description": "Notification when assets are removed from a collection",
"variables": {**event_vars, "removed_assets": "List of removed asset IDs (strings)"},
},
"message_collection_renamed": {
"description": "Notification when a collection is renamed",
"variables": rename_vars,
},
"message_collection_deleted": {
"description": "Notification when a collection is deleted",
"variables": event_vars,
},
"message_sharing_changed": {
"description": "Notification when sharing status changes",
"variables": sharing_vars,
},
"periodic_summary_message": {
"description": "Periodic summary of all tracked collections",
"variables": {**scheduled_vars, "collections": "List of collection dicts (use {% for album in collections %})"},
"album_fields": album_fields,
},
"scheduled_assets_message": {
"description": "Scheduled asset delivery (daily photo picks)",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"asset_fields": asset_fields,
},
"memory_mode_message": {
"description": "\"On This Day\" memories from previous years",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"asset_fields": asset_fields,
},
}
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -259,8 +347,9 @@ async def preview_raw(
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()
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k not in ("user_id", "created_at")} | {
"user_id": c.user_id,
"created_at": c.created_at.isoformat(),
}
@@ -0,0 +1,253 @@
"""Tracker-Target link management API routes."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, 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 (
NotificationTarget,
TemplateConfig,
Tracker,
TrackerTarget,
TrackingConfig,
User,
)
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
class TrackerTargetCreate(BaseModel):
target_id: int
tracking_config_id: int | None = None
template_config_id: int | None = None
enabled: bool = True
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
commands_config: dict[str, Any] | None = None
class TrackerTargetUpdate(BaseModel):
tracking_config_id: int | None = None
template_config_id: int | None = None
enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
commands_config: dict[str, Any] | None = None
@router.get("")
async def list_tracker_targets(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all target links for a tracker."""
await _get_user_tracker(session, tracker_id, user.id)
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
)
return [await _tt_response(session, tt) for tt in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_tracker_target(
tracker_id: int,
body: TrackerTargetCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Link a target to a tracker with per-link configuration."""
await _get_user_tracker(session, tracker_id, user.id)
# Validate target exists and belongs to user
target = await session.get(NotificationTarget, body.target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
# Check for duplicate link
result = await session.exec(
select(TrackerTarget).where(
TrackerTarget.tracker_id == tracker_id,
TrackerTarget.target_id == body.target_id,
)
)
if result.first():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Target is already linked to this tracker",
)
# Validate config ownership
if body.tracking_config_id:
tc = await session.get(TrackingConfig, body.tracking_config_id)
if not tc or tc.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
if body.template_config_id:
tpc = await session.get(TemplateConfig, body.template_config_id)
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
tt = TrackerTarget(tracker_id=tracker_id, **body.model_dump())
session.add(tt)
await session.commit()
await session.refresh(tt)
return await _tt_response(session, tt)
@router.put("/{tracker_target_id}")
async def update_tracker_target(
tracker_id: int,
tracker_target_id: int,
body: TrackerTargetUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a tracker-target link's configuration."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
updates = body.model_dump(exclude_unset=True)
# Validate config ownership if being changed
if "tracking_config_id" in updates and updates["tracking_config_id"]:
tc = await session.get(TrackingConfig, updates["tracking_config_id"])
if not tc or tc.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
if "template_config_id" in updates and updates["template_config_id"]:
tpc = await session.get(TemplateConfig, updates["template_config_id"])
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
for field, value in updates.items():
setattr(tt, field, value)
session.add(tt)
await session.commit()
await session.refresh(tt)
return await _tt_response(session, tt)
@router.delete("/{tracker_target_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tracker_target(
tracker_id: int,
tracker_target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Remove a target link from a tracker."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
await session.delete(tt)
await session.commit()
@router.post("/{tracker_target_id}/test")
async def test_tracker_target(
tracker_id: int,
tracker_target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a specific linked target."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
target = await session.get(NotificationTarget, tt.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target not found")
from ..services.notifier import send_test_notification
r = await send_test_notification(target)
return {"target": target.name, **r}
@router.post("/{tracker_target_id}/test-periodic")
async def test_periodic_tracker_target(
tracker_id: int,
tracker_target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test periodic summary to a specific linked target."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
target = await session.get(NotificationTarget, tt.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target not found")
template_config = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.periodic_summary_message if template_config else "") or ""
from ..services.notifier import send_test_template_notification
r = await send_test_template_notification(target, "periodic_summary", template_str)
return {"target": target.name, **r}
@router.post("/{tracker_target_id}/test-memory")
async def test_memory_tracker_target(
tracker_id: int,
tracker_target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test memory/on-this-day notification to a specific linked target."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
target = await session.get(NotificationTarget, tt.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target not found")
template_config = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.memory_mode_message if template_config else "") or ""
from ..services.notifier import send_test_template_notification
r = await send_test_template_notification(target, "memory_mode", template_str)
return {"target": target.name, **r}
async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
"""Build tracker-target response with target details."""
target = await session.get(NotificationTarget, tt.target_id)
return {
"id": tt.id,
"tracker_id": tt.tracker_id,
"target_id": tt.target_id,
"target_name": target.name if target else None,
"target_type": target.type if target else None,
"target_icon": target.icon if target else None,
"tracking_config_id": tt.tracking_config_id,
"template_config_id": tt.template_config_id,
"enabled": tt.enabled,
"quiet_hours_start": tt.quiet_hours_start,
"quiet_hours_end": tt.quiet_hours_end,
"commands_config": tt.commands_config,
"created_at": tt.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
@@ -7,7 +7,15 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import EventLog, NotificationTarget, ServiceProvider, Tracker, User
from ..database.models import (
EventLog,
NotificationTarget,
ServiceProvider,
Tracker,
TrackerState,
TrackerTarget,
User,
)
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
@@ -17,24 +25,18 @@ class TrackerCreate(BaseModel):
name: str
icon: str = ""
collection_ids: list[str] = []
target_ids: list[int] = []
tracking_config_id: int | None = None
scan_interval: int = 60
batch_duration: int = 0
enabled: bool = True
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackerUpdate(BaseModel):
name: str | None = None
icon: str | None = None
collection_ids: list[str] | None = None
target_ids: list[int] | None = None
tracking_config_id: int | None = None
scan_interval: int | None = None
batch_duration: int | None = None
enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
@@ -45,7 +47,8 @@ async def list_trackers(
result = await session.exec(
select(Tracker).where(Tracker.user_id == user.id)
)
return [_tracker_response(t) for t in result.all()]
trackers = result.all()
return [await _tracker_response(session, t) for t in trackers]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -62,7 +65,10 @@ async def create_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return _tracker_response(tracker)
if tracker.enabled:
from ..services.scheduler import schedule_tracker
await schedule_tracker(tracker.id, tracker.scan_interval)
return await _tracker_response(session, tracker)
@router.get("/{tracker_id}")
@@ -71,7 +77,8 @@ async def get_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
return _tracker_response(await _get_user_tracker(session, tracker_id, user.id))
tracker = await _get_user_tracker(session, tracker_id, user.id)
return await _tracker_response(session, tracker)
@router.put("/{tracker_id}")
@@ -87,7 +94,12 @@ async def update_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return _tracker_response(tracker)
from ..services.scheduler import schedule_tracker, unschedule_tracker
if tracker.enabled:
await schedule_tracker(tracker.id, tracker.scan_interval)
else:
await unschedule_tracker(tracker.id)
return await _tracker_response(session, tracker)
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -97,8 +109,29 @@ async def delete_tracker(
session: AsyncSession = Depends(get_session),
):
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Delete associated tracker-target links
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
)
for tt in result.all():
await session.delete(tt)
# Delete associated tracker state
state_result = await session.exec(
select(TrackerState).where(TrackerState.tracker_id == tracker_id)
)
for ts in state_result.all():
await session.delete(ts)
# Nullify event log references
event_result = await session.exec(
select(EventLog).where(EventLog.tracker_id == tracker_id)
)
for el in event_result.all():
el.tracker_id = None
session.add(el)
await session.delete(tracker)
await session.commit()
from ..services.scheduler import unschedule_tracker
await unschedule_tracker(tracker_id)
@router.post("/{tracker_id}/trigger")
@@ -119,15 +152,27 @@ async def test_periodic(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test periodic summary notification to all targets."""
"""Send a test periodic summary notification using actual templates."""
from ..services.notifier import send_test_template_notification
from ..database.models import TemplateConfig
tracker = await _get_user_tracker(session, tracker_id, user.id)
from ..services.notifier import send_test_notification
result = await session.exec(
select(TrackerTarget).where(
TrackerTarget.tracker_id == tracker.id,
TrackerTarget.enabled == True,
)
)
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})
for tt in result.all():
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
template_config = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.periodic_summary_message if template_config else "") or ""
r = await send_test_template_notification(target, "periodic_summary", template_str)
results.append({"target": target.name, **r})
return {"test": "periodic_summary", "results": results}
@@ -137,15 +182,27 @@ async def test_memory(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test memory/on-this-day notification to all targets."""
"""Send a test memory/on-this-day notification using actual templates."""
from ..services.notifier import send_test_template_notification
from ..database.models import TemplateConfig
tracker = await _get_user_tracker(session, tracker_id, user.id)
from ..services.notifier import send_test_notification
result = await session.exec(
select(TrackerTarget).where(
TrackerTarget.tracker_id == tracker.id,
TrackerTarget.enabled == True,
)
)
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})
for tt in result.all():
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
template_config = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.memory_mode_message if template_config else "") or ""
r = await send_test_template_notification(target, "memory_mode", template_str)
results.append({"target": target.name, **r})
return {"test": "memory_mode", "results": results}
@@ -176,19 +233,39 @@ async def tracker_history(
]
def _tracker_response(t: Tracker) -> dict:
async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
"""Build tracker response with nested tracker_targets."""
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
)
tracker_targets = []
for tt in result.all():
target = await session.get(NotificationTarget, tt.target_id)
tracker_targets.append({
"id": tt.id,
"target_id": tt.target_id,
"target_name": target.name if target else None,
"target_type": target.type if target else None,
"target_icon": target.icon if target else None,
"tracking_config_id": tt.tracking_config_id,
"template_config_id": tt.template_config_id,
"enabled": tt.enabled,
"quiet_hours_start": tt.quiet_hours_start,
"quiet_hours_end": tt.quiet_hours_end,
"commands_config": tt.commands_config,
"created_at": tt.created_at.isoformat(),
})
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,
"batch_duration": t.batch_duration,
"enabled": t.enabled,
"quiet_hours_start": t.quiet_hours_start,
"quiet_hours_end": t.quiet_hours_end,
"tracker_targets": tracker_targets,
"created_at": t.created_at.isoformat(),
}
@@ -152,8 +152,8 @@ async def delete_config(
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()
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k not in ("user_id", "created_at")} | {
"created_at": c.created_at.isoformat(),
}