+{/if}
diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte
index 6f935ba..2c7c3cf 100644
--- a/frontend/src/routes/telegram-bots/+page.svelte
+++ b/frontend/src/routes/telegram-bots/+page.svelte
@@ -1,8 +1,47 @@
-
-
+{/if}
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte
index 311c914..0f42aa8 100644
--- a/frontend/src/routes/template-configs/+page.svelte
+++ b/frontend/src/routes/template-configs/+page.svelte
@@ -1,8 +1,51 @@
-
-
+{/if}
diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte
index 42a8624..cf69bcf 100644
--- a/frontend/src/routes/trackers/+page.svelte
+++ b/frontend/src/routes/trackers/+page.svelte
@@ -1,34 +1,53 @@
-
-
-
{t('nav.trackers')}
-
+
- {#if loading}
-
{t('common.loading')}
- {:else if trackers.length === 0}
-
{t('common.noData')}
- {:else}
-
- {#each trackers as tracker}
-
-
{tracker.name}
-
{tracker.collection_ids?.length || 0} collections
-
- {/each}
+{#if !loaded}
+
+{:else if trackers.length === 0}
+
+
+
+
{t('trackers.noTrackers')}
- {/if}
-
+
+{:else}
+
+ {#each trackers as tracker}
+
+
+
+
+
+
+
{tracker.name}
+
+ {tracker.collection_ids?.length || 0} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s
+
+
+
+ {tracker.enabled ? t('trackers.active') : t('trackers.paused')}
+
+
+
+ {/each}
+
+{/if}
diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte
index 7f0ad42..ae26dad 100644
--- a/frontend/src/routes/tracking-configs/+page.svelte
+++ b/frontend/src/routes/tracking-configs/+page.svelte
@@ -1,8 +1,47 @@
-
-
-
{t('nav.trackingConfigs')}
-
Tracking configuration management — coming soon.
-
+
+
+{#if !loaded}
+
+{:else if configs.length === 0}
+
+
+
+
{t('trackingConfig.noConfigs')}
+
+
+{:else}
+
+ {#each configs as config}
+
+
+
+
+
+
+
{config.name}
+
{config.provider_type}
+
+
+
+ {/each}
+
+{/if}
diff --git a/frontend/src/routes/users/+page.svelte b/frontend/src/routes/users/+page.svelte
index 942466a..dd74d3f 100644
--- a/frontend/src/routes/users/+page.svelte
+++ b/frontend/src/routes/users/+page.svelte
@@ -1,8 +1,47 @@
-
-
-
{t('nav.users')}
-
User management — coming soon.
-
+
+
+{#if !loaded}
+
+{:else if users.length === 0}
+
+
+
+{:else}
+
+ {#each users as user}
+
+
+
+ {user.username[0].toUpperCase()}
+
+
+
{user.username}
+
{user.role}
+
+
+
+ {/each}
+
+{/if}
diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py
index f125c2e..e1065d9 100644
--- a/packages/server/src/notify_bridge_server/api/providers.py
+++ b/packages/server/src/notify_bridge_server/api/providers.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/api/status.py b/packages/server/src/notify_bridge_server/api/status.py
new file mode 100644
index 0000000..421a263
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/api/status.py
@@ -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()
+ ],
+ }
diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py
index 14c4a00..f98deb2 100644
--- a/packages/server/src/notify_bridge_server/api/targets.py
+++ b/packages/server/src/notify_bridge_server/api/targets.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/api/telegram_bots.py b/packages/server/src/notify_bridge_server/api/telegram_bots.py
new file mode 100644
index 0000000..647a1e6
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/api/telegram_bots.py
@@ -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", ""),
+ ))
diff --git a/packages/server/src/notify_bridge_server/api/template_configs.py b/packages/server/src/notify_bridge_server/api/template_configs.py
index c9d93d1..7b3a3d4 100644
--- a/packages/server/src/notify_bridge_server/api/template_configs.py
+++ b/packages/server/src/notify_bridge_server/api/template_configs.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/api/trackers.py b/packages/server/src/notify_bridge_server/api/trackers.py
index 519554c..1de25be 100644
--- a/packages/server/src/notify_bridge_server/api/trackers.py
+++ b/packages/server/src/notify_bridge_server/api/trackers.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/api/tracking_configs.py b/packages/server/src/notify_bridge_server/api/tracking_configs.py
index 64d7c3c..5538e99 100644
--- a/packages/server/src/notify_bridge_server/api/tracking_configs.py
+++ b/packages/server/src/notify_bridge_server/api/tracking_configs.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/api/users.py b/packages/server/src/notify_bridge_server/api/users.py
new file mode 100644
index 0000000..65387fb
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/api/users.py
@@ -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()
diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py
index 7c496c9..5a66539 100644
--- a/packages/server/src/notify_bridge_server/main.py
+++ b/packages/server/src/notify_bridge_server/main.py
@@ -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")