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
@@ -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