feat(notify-bridge): phase 6 - database models and server API

New database schema with ServiceProvider abstraction:
- ServiceProvider (replaces ImmichServer): type + JSON config
- Tracker (replaces AlbumTracker): owns tracking_config_id
- TrackingConfig: provider_type scoped, owned by Tracker
- TemplateConfig: provider_type scoped, owned by Target
- NotificationTarget: owns template_config_id (not tracking_config_id)
- TrackerState, EventLog, User, TelegramBot, TelegramChat

Full FastAPI server:
- /api/providers: CRUD + test connection + list collections
- /api/trackers: CRUD
- /api/tracking-configs: CRUD with provider_type filter
- /api/template-configs: CRUD with provider_type filter, system defaults
- /api/targets: CRUD
- /api/template-vars: variable docs filtered by provider type
- /api/auth: setup, login, refresh, me, password change
- /api/health: health check
- Default template seeding on first startup (EN/RU for Immich)
- pydantic-settings with NOTIFY_BRIDGE_ env prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:39:23 +03:00
parent 16a41efec1
commit 7f99c895a4
14 changed files with 1116 additions and 9 deletions
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"bcrypt>=4.2",
"apscheduler>=3.10,<4",
"aiohttp>=3.9",
"pydantic-settings>=2.0",
"anthropic>=0.42",
]
@@ -0,0 +1,159 @@
"""Service Provider CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
router = APIRouter(prefix="/api/providers", tags=["providers"])
class ProviderCreate(BaseModel):
type: str
name: str
icon: str = ""
config: dict[str, Any] = {}
class ProviderUpdate(BaseModel):
name: str | None = None
icon: str | None = None
config: dict[str, Any] | None = None
@router.get("")
async def list_providers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(ServiceProvider).where(ServiceProvider.user_id == user.id)
)
return result.all()
@router.post("", status_code=201)
async def create_provider(
body: ProviderCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
provider = ServiceProvider(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
)
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
@router.get("/{provider_id}")
async def get_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")
return provider
@router.put("/{provider_id}")
async def update_provider(
provider_id: int,
body: ProviderUpdate,
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")
if body.name is not None:
provider.name = body.name
if body.icon is not None:
provider.icon = body.icon
if body.config is not None:
provider.config = body.config
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
@router.delete("/{provider_id}", status_code=204)
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")
await session.delete(provider)
await session.commit()
@router.post("/{provider_id}/test")
async def test_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")
if provider.type == "immich":
import aiohttp
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,
)
return await immich.test_connection()
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@router.get("/{provider_id}/collections")
async def list_collections(
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")
if provider.type == "immich":
import aiohttp
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,
)
return await immich.list_collections()
return []
@@ -0,0 +1,97 @@
"""NotificationTarget CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
class TargetCreate(BaseModel):
type: str
name: str
icon: str = ""
config: dict[str, Any] = {}
template_config_id: int | None = None
class TargetUpdate(BaseModel):
name: str | None = None
icon: str | None = None
config: dict[str, Any] | None = None
template_config_id: int | None = None
@router.get("")
async def list_targets(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return result.all()
@router.post("", status_code=201)
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())
session.add(target)
await session.commit()
await session.refresh(target)
return target
@router.get("/{target_id}")
async def get_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")
return target
@router.put("/{target_id}")
async def update_target(
target_id: int,
body: TargetUpdate,
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)
session.add(target)
await session.commit()
await session.refresh(target)
return target
@router.delete("/{target_id}", status_code=204)
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")
await session.delete(target)
await session.commit()
@@ -0,0 +1,85 @@
"""TemplateConfig CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
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 TemplateConfig, User
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
@router.get("")
async def list_template_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
query = select(TemplateConfig).where(
(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()
@router.post("", status_code=201)
async def create_template_config(
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TemplateConfig(user_id=user.id, **body)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.get("/{config_id}")
async def get_template_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
@router.put("/{config_id}")
async def update_template_config(
config_id: int,
body: dict,
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"):
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.delete("/{config_id}", status_code=204)
async def delete_template_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")
await session.delete(config)
await session.commit()
@@ -0,0 +1,33 @@
"""Template variable documentation endpoint."""
from fastapi import APIRouter
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.providers.immich import ImmichServiceProvider # noqa: F401 — triggers registration
from notify_bridge_core.templates.variables import registry
router = APIRouter(prefix="/api/template-vars", tags=["template-vars"])
@router.get("")
async def get_template_variables(provider_type: str | None = None):
"""Get available template variables, optionally filtered by provider type."""
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
]
@@ -0,0 +1,104 @@
"""Tracker CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
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
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
class TrackerCreate(BaseModel):
provider_id: int
name: str
icon: str = ""
collection_ids: list[str] = []
target_ids: list[int] = []
tracking_config_id: int | None = None
scan_interval: int = 60
enabled: bool = True
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackerUpdate(BaseModel):
name: str | None = None
icon: str | None = None
collection_ids: list[str] | None = None
target_ids: list[int] | None = None
tracking_config_id: int | None = None
scan_interval: int | None = None
enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
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()
@router.post("", status_code=201)
async def create_tracker(
body: TrackerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = Tracker(user_id=user.id, **body.model_dump())
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return tracker
@router.get("/{tracker_id}")
async def get_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")
return tracker
@router.put("/{tracker_id}")
async def update_tracker(
tracker_id: int,
body: TrackerUpdate,
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")
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
@router.delete("/{tracker_id}", status_code=204)
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")
await session.delete(tracker)
await session.commit()
@@ -0,0 +1,83 @@
"""TrackingConfig CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
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 TrackingConfig, User
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
@router.get("")
async def list_tracking_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
query = select(TrackingConfig).where(TrackingConfig.user_id == user.id)
if provider_type:
query = query.where(TrackingConfig.provider_type == provider_type)
result = await session.exec(query)
return result.all()
@router.post("", status_code=201)
async def create_tracking_config(
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TrackingConfig(user_id=user.id, **body)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.get("/{config_id}")
async def get_tracking_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
@router.put("/{config_id}")
async def update_tracking_config(
config_id: int,
body: dict,
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)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.delete("/{config_id}", status_code=204)
async def delete_tracking_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")
await session.delete(config)
await session.commit()
@@ -0,0 +1,37 @@
"""FastAPI dependencies for authentication."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel.ext.asyncio.session import AsyncSession
import jwt
from ..database.engine import get_session
from ..database.models import User
from .jwt import decode_token
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
try:
payload = decode_token(credentials.credentials)
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user_id = int(payload["sub"])
except (jwt.PyJWTError, KeyError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
@@ -0,0 +1,25 @@
"""JWT token creation and validation."""
from datetime import datetime, timedelta, timezone
import jwt
from ..config import settings
ALGORITHM = "HS256"
def create_access_token(user_id: int, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(user_id), "role": role, "type": "access", "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload = {"sub": str(user_id), "type": "refresh", "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
@@ -0,0 +1,134 @@
"""Authentication API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
import bcrypt
from ..database.engine import get_session
from ..database.models import User
from .dependencies import get_current_user
from .jwt import create_access_token, create_refresh_token, decode_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
class SetupRequest(BaseModel):
username: str
password: str
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: int
username: str
role: str
class RefreshRequest(BaseModel):
refresh_token: str
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
@router.post("/setup", response_model=TokenResponse)
async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)):
result = await session.exec(select(func.count()).select_from(User))
count = result.one()
if count > 0:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.")
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
session.add(user)
await session.commit()
await session.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
result = await session.exec(select(User).where(User.username == body.username))
user = result.first()
if not user or not _verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
import jwt as pyjwt
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = int(payload["sub"])
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
return UserResponse(id=user.id, username=user.username, role=user.role)
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
@router.put("/password")
async def change_password(
body: PasswordChangeRequest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
if not _verify_password(body.current_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
user.hashed_password = _hash_password(body.new_password)
session.add(user)
await session.commit()
return {"success": True}
@router.get("/needs-setup")
async def needs_setup(session: AsyncSession = Depends(get_session)):
result = await session.exec(select(func.count()).select_from(User))
count = result.one()
return {"needs_setup": count == 0}
@@ -1,12 +1,47 @@
"""Server configuration — settings, data directory, secrets."""
"""Server configuration from environment variables."""
import os
from pathlib import Path
from typing import Any
from pydantic_settings import BaseSettings
DATA_DIR = Path(os.environ.get("NOTIFY_BRIDGE_DATA_DIR", "./data"))
SECRET_KEY = os.environ.get("NOTIFY_BRIDGE_SECRET_KEY", "")
DATABASE_URL = os.environ.get(
"NOTIFY_BRIDGE_DATABASE_URL",
f"sqlite+aiosqlite:///{DATA_DIR / 'notify_bridge.db'}",
)
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
data_dir: Path = Path("/data")
database_url: str = ""
secret_key: str = "change-me-in-production"
def model_post_init(self, __context: Any) -> None:
if self.secret_key == "change-me-in-production" and not self.debug:
import logging
logging.getLogger(__name__).critical(
"SECURITY: Using default secret_key! "
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
)
access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 30
host: str = "0.0.0.0"
port: int = 8420
debug: bool = False
anthropic_api_key: str = ""
ai_model: str = "claude-sonnet-4-20250514"
ai_max_tokens: int = 1024
telegram_webhook_secret: str = ""
model_config = {"env_prefix": "NOTIFY_BRIDGE_"}
@property
def effective_database_url(self) -> str:
if self.database_url:
return self.database_url
db_path = self.data_dir / "notify_bridge.db"
return f"sqlite+aiosqlite:///{db_path}"
settings = Settings()
@@ -0,0 +1,33 @@
"""Database engine and session management."""
from collections.abc import AsyncGenerator
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from ..config import settings
_engine: AsyncEngine | None = None
def get_engine() -> AsyncEngine:
global _engine
if _engine is None:
_engine = create_async_engine(
settings.effective_database_url,
echo=settings.debug,
)
return _engine
async def init_db() -> None:
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
engine = get_engine()
async with AsyncSession(engine) as session:
yield session
@@ -0,0 +1,219 @@
"""SQLModel database table definitions for Notify Bridge."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlmodel import JSON, Column, Field, SQLModel
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
role: str = Field(default="user")
created_at: datetime = Field(default_factory=_utcnow)
class ServiceProvider(SQLModel, table=True):
"""A service provider instance (e.g., an Immich server)."""
__tablename__ = "service_provider"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
type: str # ServiceProviderType value ("immich")
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
class TelegramBot(SQLModel, table=True):
__tablename__ = "telegram_bot"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str
token: str
icon: str = Field(default="")
bot_username: str = Field(default="")
bot_id: int = Field(default=0)
commands_config: dict[str, Any] = Field(
default_factory=lambda: {
"enabled": ["status", "albums", "events", "summary", "latest",
"memory", "random", "search", "find", "person",
"place", "favorites", "people", "help"],
"default_count": 5,
"response_mode": "media",
"rate_limits": {"search": 30, "find": 30, "default": 10},
"locale": "en",
},
sa_column=Column(JSON),
)
created_at: datetime = Field(default_factory=_utcnow)
class TelegramChat(SQLModel, table=True):
__tablename__ = "telegram_chat"
id: int | None = Field(default=None, primary_key=True)
bot_id: int = Field(foreign_key="telegram_bot.id")
chat_id: str
title: str = Field(default="")
chat_type: str = Field(default="private")
username: str = Field(default="")
discovered_at: datetime = Field(default_factory=_utcnow)
class TrackingConfig(SQLModel, table=True):
"""What events to track + scheduling rules. Tied to a provider type."""
__tablename__ = "tracking_config"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_type: str # Must match provider's type
name: str
icon: str = Field(default="")
# Event-driven tracking
track_assets_added: bool = Field(default=True)
track_assets_removed: bool = Field(default=False)
track_collection_renamed: bool = Field(default=True)
track_collection_deleted: bool = Field(default=True)
track_sharing_changed: bool = Field(default=False)
track_images: bool = Field(default=True)
track_videos: bool = Field(default=True)
notify_favorites_only: bool = Field(default=False)
# Asset display
include_tags: bool = Field(default=True)
include_asset_details: bool = Field(default=False)
max_assets_to_show: int = Field(default=5)
assets_order_by: str = Field(default="none")
assets_order: str = Field(default="descending")
# Periodic summary
periodic_enabled: bool = Field(default=False)
periodic_interval_days: int = Field(default=1)
periodic_start_date: str = Field(default="2025-01-01")
periodic_times: str = Field(default="12:00")
# Scheduled assets
scheduled_enabled: bool = Field(default=False)
scheduled_times: str = Field(default="09:00")
scheduled_collection_mode: str = Field(default="per_collection")
scheduled_limit: int = Field(default=10)
scheduled_favorite_only: bool = Field(default=False)
scheduled_asset_type: str = Field(default="all")
scheduled_min_rating: int = Field(default=0)
scheduled_order_by: str = Field(default="random")
scheduled_order: str = Field(default="descending")
# Memory mode
memory_enabled: bool = Field(default=False)
memory_times: str = Field(default="09:00")
memory_collection_mode: str = Field(default="combined")
memory_limit: int = Field(default=10)
memory_favorite_only: bool = Field(default=False)
memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0)
created_at: datetime = Field(default_factory=_utcnow)
class TemplateConfig(SQLModel, table=True):
"""Jinja2 message templates. Tied to a provider type."""
__tablename__ = "template_config"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_type: str # Must match provider's type
name: str
description: str = Field(default="")
icon: str = Field(default="")
# Event-driven notification templates
message_assets_added: str = Field(default="")
message_assets_removed: str = Field(default="")
message_collection_renamed: str = Field(default="")
message_collection_deleted: str = Field(default="")
message_sharing_changed: str = Field(default="")
# Scheduled notification templates
periodic_summary_message: str = Field(default="")
scheduled_assets_message: str = Field(default="")
memory_mode_message: str = Field(default="")
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
created_at: datetime = Field(default_factory=_utcnow)
class NotificationTarget(SQLModel, table=True):
"""Where to send notifications. Owns the template config."""
__tablename__ = "notification_target"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
type: str # "telegram" or "webhook"
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
created_at: datetime = Field(default_factory=_utcnow)
class Tracker(SQLModel, table=True):
"""Watches a provider's collections for changes. Owns the tracking config."""
__tablename__ = "tracker"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_id: int = Field(foreign_key="service_provider.id")
name: str
icon: str = Field(default="")
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
scan_interval: int = Field(default=60)
enabled: bool = Field(default=True)
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
created_at: datetime = Field(default_factory=_utcnow)
class TrackerState(SQLModel, table=True):
"""Persisted state for change detection."""
__tablename__ = "tracker_state"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int = Field(foreign_key="tracker.id")
collection_id: str
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
last_updated: datetime = Field(default_factory=_utcnow)
class EventLog(SQLModel, table=True):
"""Log of detected events."""
__tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
event_type: str
collection_id: str
collection_name: str
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
@@ -1,8 +1,38 @@
"""Notify Bridge Server — FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
app = FastAPI(title="Notify Bridge", version="0.1.0")
from .database.engine import init_db
from .database.models import * # noqa: F401,F403 — ensure all models registered
from .auth.routes import router as auth_router
from .api.providers import router as providers_router
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.template_vars import router as template_vars_router
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
await _seed_default_templates()
yield
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
# Register routes — static paths before parameterized
app.include_router(auth_router)
app.include_router(template_vars_router)
app.include_router(providers_router)
app.include_router(trackers_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
@app.get("/api/health")
@@ -10,6 +40,38 @@ async def health():
return {"status": "ok"}
async def _seed_default_templates():
"""Seed default templates on first startup if no templates exist."""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import TemplateConfig
from notify_bridge_core.templates.defaults import load_default_templates
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(select(func.count()).select_from(TemplateConfig))
count = result.one()
if count > 0:
return
for locale in ("en", "ru"):
slots = load_default_templates(locale)
if not slots:
continue
name = f"Default ({locale.upper()})"
config = TemplateConfig(
user_id=0,
provider_type="immich",
name=name,
description=f"Default Immich templates ({locale.upper()})",
**slots,
)
session.add(config)
await session.commit()
def run():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8420)