diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 18492f9..42cd2dd 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,13 +1,165 @@ -
-

{t('dashboard.noEvents')}

-
+{#if !loaded} + +{:else if error} + +
+ +

{error}

+
+
+{:else if status} +
+ {#each statCards as card, i} +
+
+
+
+ +
+
+

{t(card.label)}

+

+ {card.value}{#if card.suffix}{card.suffix}{/if} +

+
+
+
+
+ {/each} +
+ +

+ + {t('dashboard.recentEvents')} +

+ {#if status.recent_events.length === 0} + +
+
+

{t('dashboard.noEvents')}

+
+
+ {:else} +
+ {#each status.recent_events as event, i} +
+
+ {#if i < status.recent_events.length - 1}
{/if} +
+
+
+ + + + {event.collection_name} + {event.event_type.replace('_', ' ')} +
+ {timeAgo(event.created_at)} +
+
+
+ {/each} +
+ {/if} +{/if} + + diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index ba8c5ee..e027fbf 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -1,49 +1,96 @@ -
-
-

{t('nav.providers')}

- - {t('provider.addProvider')} - -
+ + { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }} + onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}> + + {t('providers.addProvider')} + + - {#if loading} -

{t('common.loading')}

- {:else if providers.length === 0} -
-

{t('common.noData')}

-

Add your first service provider to get started.

+{#if !loaded} + +{:else if providers.length === 0} + +
+
+ +
+

{t('providers.noProviders')}

- {:else} -
- {#each providers as provider} -
-
-
- {provider.type[0].toUpperCase()} + +{:else} +
+ {#each providers as provider} + +
+
+
+
-

{provider.name}

-

{provider.type}

+

{provider.name}

+

{provider.type}

+ deleteTarget = provider} />
- {/each} -
- {/if} -
+ + {/each} +
+{/if} + + deleteTarget = null} +/> diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 3c857b6..5b2d1fc 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,34 +1,47 @@ -
-
-

{t('nav.targets')}

-
+ - {#if loading} -

{t('common.loading')}

- {:else if targets.length === 0} -

{t('common.noData')}

- {:else} -
- {#each targets as target} -
-

{target.name}

-

{target.type}

-
- {/each} +{#if !loaded} + +{:else if targets.length === 0} + +
+
+

{t('targets.noTargets')}

- {/if} -
+ +{:else} +
+ {#each targets as target} + +
+
+ +
+
+

{target.name}

+

{target.type}

+
+
+
+ {/each} +
+{/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 @@ - -
-

{t('nav.telegramBots')}

-

Telegram bot management — coming soon.

-
+ + +{#if !loaded} + +{:else if bots.length === 0} + +
+
+

{t('telegramBot.noBots')}

+
+
+{:else} +
+ {#each bots as bot} + +
+
+ +
+
+

{bot.name}

+

@{bot.bot_username || '...'}

+
+
+
+ {/each} +
+{/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 @@ - -
-

{t('nav.templateConfigs')}

-

Template configuration management — coming soon.

-
+ + +{#if !loaded} + +{:else if configs.length === 0} + +
+
+

{t('templateConfig.noConfigs')}

+
+
+{:else} +
+ {#each configs as config} + +
+
+ +
+
+

{config.name}

+

{config.description || config.provider_type}

+
+ {#if config.user_id === 0} + System + {/if} +
+
+ {/each} +
+{/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} + +
+
+

No users found.

+
+
+{: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")