"""SQLModel database table definitions for Notify Bridge.""" from __future__ import annotations from datetime import datetime, timezone from typing import Any from uuid import uuid4 from sqlalchemy import ForeignKey, Index, UniqueConstraint, Text 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") token_version: int = Field(default=1) 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( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) type: str # ServiceProviderType value ("immich") name: str icon: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) # Webhook token is the shared secret embedded in inbound webhook URLs. # Must be unique so a token uniquely identifies a provider; indexed so # the webhook router does an O(log n) lookup on every inbound request. webhook_token: str = Field( default_factory=lambda: uuid4().hex, unique=True, index=True, ) 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( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) name: str token: str icon: str = Field(default="") bot_username: str = Field(default="") # bot_id=0 is a sentinel meaning "Telegram has not yet returned a numeric # ID for this bot" (i.e. token never validated). Multiple unverified bots # may legitimately carry 0, so we only enforce uniqueness for non-sentinel # values via a partial index added in migrate_uniqueness_constraints. bot_id: int = Field(default=0, index=True) # URL-path embedded in Telegram's setWebhook callback URL. Must be unique # so the inbound dispatcher resolves a single bot per incoming request. webhook_path_id: str = Field( default_factory=lambda: uuid4().hex, unique=True, index=True, ) update_mode: str = Field(default="none") # "none", "polling", or "webhook" # NOTE: commands_config column remains in the DB for backward compat, # but is no longer part of the SQLModel class. Data migrated to CommandConfig. created_at: datetime = Field(default_factory=_utcnow) class MatrixBot(SQLModel, table=True): """Matrix bot — homeserver connection for sending messages to rooms.""" __tablename__ = "matrix_bot" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) name: str icon: str = Field(default="") homeserver_url: str # e.g. https://matrix.org access_token: str display_name: str = Field(default="") created_at: datetime = Field(default_factory=_utcnow) class EmailBot(SQLModel, table=True): """Email sender — SMTP connection for sending email notifications.""" __tablename__ = "email_bot" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) name: str icon: str = Field(default="") email: str # From address smtp_host: str smtp_port: int = Field(default=587) smtp_username: str = Field(default="") smtp_password: str = Field(default="") smtp_use_tls: bool = Field(default=True) created_at: datetime = Field(default_factory=_utcnow) class TelegramChat(SQLModel, table=True): __tablename__ = "telegram_chat" # (bot_id, chat_id) uniquely identifies a chat. The composite index is # the access pattern for save_chat_from_webhook ON CONFLICT updates and # for any "lookup by (bot, chat)" callers. __table_args__ = ( UniqueConstraint("bot_id", "chat_id", name="uq_telegram_chat_bot_chat"), Index("ix_telegram_chat_bot_chat", "bot_id", "chat_id"), ) 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="") language_code: str = Field(default="") # auto-detected from Telegram language_override: str = Field(default="") # manual override set by user commands_enabled: bool = Field(default=False) 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( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) 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) # Gitea event tracking track_push: bool = Field(default=True) track_issue_opened: bool = Field(default=True) track_issue_closed: bool = Field(default=True) track_issue_commented: bool = Field(default=False) track_pr_opened: bool = Field(default=True) track_pr_closed: bool = Field(default=True) track_pr_merged: bool = Field(default=True) track_pr_commented: bool = Field(default=False) track_release_published: bool = Field(default=True) # Planka event tracking track_card_created: bool = Field(default=True) track_card_updated: bool = Field(default=False) track_card_moved: bool = Field(default=True) track_card_deleted: bool = Field(default=False) track_card_commented: bool = Field(default=True) track_comment_updated: bool = Field(default=False) track_board_created: bool = Field(default=True) track_board_updated: bool = Field(default=False) track_board_deleted: bool = Field(default=True) track_list_created: bool = Field(default=False) track_list_updated: bool = Field(default=False) track_list_deleted: bool = Field(default=False) track_attachment_created: bool = Field(default=True) track_card_label_added: bool = Field(default=False) track_task_completed: bool = Field(default=True) # Scheduler event tracking track_scheduled_message: bool = Field(default=True) # NUT (UPS) event tracking track_ups_online: bool = Field(default=True) track_ups_on_battery: bool = Field(default=True) track_ups_low_battery: bool = Field(default=True) track_ups_battery_restored: bool = Field(default=True) track_ups_comms_lost: bool = Field(default=True) track_ups_comms_restored: bool = Field(default=True) track_ups_replace_battery: bool = Field(default=True) track_ups_overload: bool = Field(default=True) # Generic Webhook event tracking track_webhook_received: bool = Field(default=True) # Home Assistant event tracking track_ha_state_changed: bool = Field(default=True) track_ha_automation_triggered: bool = Field(default=False) track_ha_service_called: bool = Field(default=False) track_ha_event_fired: bool = Field(default=False) # Bridge self-monitoring event tracking — defaults ON because the whole # point of the provider is to alert on these conditions. track_bridge_self_poll_failures: bool = Field(default=True) track_bridge_self_deferred_backlog: bool = Field(default=True) track_bridge_self_target_failures: bool = Field(default=True) # Immich asset display 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=10) 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_source: str = Field(default="albums") # "albums" or "native" 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) # Quiet hours — HH:MM strings interpreted in the app-level timezone # (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window # still represents "explicitly disabled" vs "not yet configured". quiet_hours_enabled: bool = Field(default=False) quiet_hours_start: str | None = Field(default=None) quiet_hours_end: str | None = Field(default=None) created_at: datetime = Field(default_factory=_utcnow) class TemplateConfig(SQLModel, table=True): """Jinja2 message templates. Tied to a provider type. Template content is stored in TemplateSlot child rows (one per slot). """ __tablename__ = "template_config" id: int | None = Field(default=None, primary_key=True) user_id: int = Field(default=0) # 0 = system-owned (no FK to allow sentinel) provider_type: str # Must match provider's type name: str description: str = Field(default="") icon: str = Field(default="") locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified date_format: str = Field(default="%d.%m.%Y, %H:%M UTC") date_only_format: str = Field(default="%d.%m.%Y") created_at: datetime = Field(default_factory=_utcnow) class TemplateSlot(SQLModel, table=True): """One Jinja2 template for a specific slot and locale within a TemplateConfig. Slot names are provider-specific (e.g. 'message_assets_added' for Immich). Each (config, slot, locale) triple holds a separate template. """ __tablename__ = "template_slot" __table_args__ = ( UniqueConstraint("config_id", "slot_name", "locale", name="uq_template_slot_locale"), ) id: int | None = Field(default=None, primary_key=True) config_id: int = Field( foreign_key="template_config.id", index=True, ) slot_name: str locale: str = Field(default="en") template: str = Field(default="", sa_column=Column(Text, default="")) class NotificationTarget(SQLModel, table=True): """Where to send notifications. Pure delivery endpoint. Target-level config holds connection/display settings (e.g. bot_token, disable_url_preview). Actual delivery endpoints live in TargetReceiver rows. """ __tablename__ = "notification_target" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix" name: str icon: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) chat_action: str | None = Field(default="typing") # e.g. "typing", "upload_photo" created_at: datetime = Field(default_factory=_utcnow) class TargetReceiver(SQLModel, table=True): """One delivery endpoint within a NotificationTarget (broadcast support). For Telegram: config = {"chat_id": "12345"} For Webhook: config = {"url": "https://...", "headers": {...}} For Email: config = {"email": "user@example.com", "name": "..."} """ __tablename__ = "target_receiver" __table_args__ = ( UniqueConstraint("target_id", "receiver_key", name="uq_target_receiver"), ) id: int | None = Field(default=None, primary_key=True) target_id: int = Field( foreign_key="notification_target.id", index=True, ) name: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email) locale: str = Field(default="") # e.g. "en", "ru"; empty = use target default enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=_utcnow) class NotificationTracker(SQLModel, table=True): """Watches a provider's collections for changes.""" __tablename__ = "notification_tracker" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) 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)) filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) scan_interval: int = Field(default=60) # Cap on the adaptive-polling skip factor (see services/scheduler.py). # None or 0 disables adaptive back-off entirely — every scheduled tick # runs. Positive values (2..N) enable skipping up to (N-1) out of N ticks # once the tracker has been idle long enough. Per-tracker so an operator # can opt a latency-sensitive tracker out of the global heuristic. adaptive_max_skip: int | None = Field(default=None) default_tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id") default_template_config_id: int | None = Field(default=None, foreign_key="template_config.id") enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=_utcnow) class NotificationTrackerTarget(SQLModel, table=True): """Junction between NotificationTracker and NotificationTarget with per-link config.""" __tablename__ = "notification_tracker_target" # A tracker should never link to the same target twice — duplicate links # would deliver the same notification multiple times. Enforced at the DB # level so concurrent inserts can't bypass an application-side check. __table_args__ = ( UniqueConstraint( "notification_tracker_id", "target_id", name="uq_ntt_tracker_target", ), ) id: int | None = Field(default=None, primary_key=True) # Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id tracker_id: int = Field( foreign_key="notification_tracker.id", index=True, sa_column_kwargs={"name": "notification_tracker_id"}, ) target_id: int = Field( foreign_key="notification_target.id", index=True, ) tracking_config_id: int | None = Field( default=None, foreign_key="tracking_config.id" ) template_config_id: int | None = Field( default=None, foreign_key="template_config.id" ) enabled: bool = Field(default=True) quiet_hours_start: str | None = None quiet_hours_end: str | None = None # NOTE: commands_config column remains in the DB for backward compat, # but is no longer part of the SQLModel class. Data migrated to CommandConfig. created_at: datetime = Field(default_factory=_utcnow) class NotificationTrackerState(SQLModel, table=True): """Persisted state for change detection.""" __tablename__ = "notification_tracker_state" id: int | None = Field(default=None, primary_key=True) # Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id tracker_id: int = Field( foreign_key="notification_tracker.id", index=True, sa_column_kwargs={"name": "notification_tracker_id"}, ) collection_id: str collection_name: str = Field(default="") shared: bool = Field(default=False) 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)) # Lightweight fingerprint ({updated_at, asset_count, shared, name, ...}) # captured from the provider's cheap meta probe. Letting this differ from # the current provider response is what tells the watcher a full fetch is # actually required — letting it match lets the watcher skip the big read. meta_fingerprint: dict[str, Any] = Field( default_factory=dict, sa_column=Column(JSON) ) last_updated: datetime = Field(default_factory=_utcnow) class CommandConfig(SQLModel, table=True): """Configuration for bot commands (e.g., which commands are enabled, rate limits).""" __tablename__ = "command_config" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) provider_type: str name: str icon: str = Field(default="") enabled_commands: list[str] = Field(default_factory=list, sa_column=Column(JSON)) locale: str = Field(default="en") response_mode: str = Field(default="media") # "media" or "text" default_count: int = Field(default=5) rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) command_template_config_id: int | None = Field( default=None, foreign_key="command_template_config.id" ) created_at: datetime = Field(default_factory=_utcnow) class CommandTemplateConfig(SQLModel, table=True): """Jinja2 templates for command responses. Provider-specific via slots.""" __tablename__ = "command_template_config" id: int | None = Field(default=None, primary_key=True) user_id: int = Field(default=0) # 0 = system-owned provider_type: str name: str description: str = Field(default="") icon: str = Field(default="") locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified created_at: datetime = Field(default_factory=_utcnow) class CommandTemplateSlot(SQLModel, table=True): """One Jinja2 template for a specific command response slot and locale. Slot names match command names (e.g. 'status', 'help', 'albums'). Description slots use 'desc_' prefix (e.g. 'desc_status', 'desc_help'). Each (config, slot, locale) triple holds a separate template. """ __tablename__ = "command_template_slot" __table_args__ = ( UniqueConstraint("config_id", "slot_name", "locale", name="uq_cmd_slot_locale"), ) id: int | None = Field(default=None, primary_key=True) config_id: int = Field( foreign_key="command_template_config.id", index=True, ) slot_name: str locale: str = Field(default="en") template: str = Field(default="", sa_column=Column(Text, default="")) class CommandTracker(SQLModel, table=True): """Links a provider to a command config for interactive bot commands.""" __tablename__ = "command_tracker" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) provider_id: int = Field(foreign_key="service_provider.id") command_config_id: int = Field(foreign_key="command_config.id") name: str icon: str = Field(default="") enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=_utcnow) class CommandTrackerListener(SQLModel, table=True): """Links a CommandTracker to a listener (e.g., a telegram bot chat).""" __tablename__ = "command_tracker_listener" __table_args__ = ( UniqueConstraint( "command_tracker_id", "listener_type", "listener_id", name="uq_command_tracker_listener", ), ) id: int | None = Field(default=None, primary_key=True) command_tracker_id: int = Field( foreign_key="command_tracker.id", index=True, ) listener_type: str # e.g. "telegram_bot" listener_id: int # Optional per-chat album scope. None = inherit from tracker (use all). # When set, only these album/collection ids are queryable from this chat. allowed_album_ids: list[str] | None = Field( default=None, sa_column=Column(JSON, nullable=True), ) created_at: datetime = Field(default_factory=_utcnow) class DeferredDispatch(SQLModel, table=True): """A dispatch held back by quiet hours, waiting for the window to end. One row per ``(link, event_type, collection_id)`` for asset events — newly arriving events for the same key coalesce into the existing row's ``event_payload`` (union of added/removed asset sets) instead of inserting a duplicate row. Non-asset events (push, pr_opened, ups_*, …) get a fresh row each time because they aren't logically cancellable. At drain time the scheduler picks up rows where ``status='pending'`` and ``fire_at <= now``, re-resolves the link/target/config against current state (so subsequent config edits apply), and dispatches. """ __tablename__ = "deferred_dispatch" id: int | None = Field(default=None, primary_key=True) user_id: int | None = Field( default=None, sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=True, index=True, ), ) tracker_id: int = Field(foreign_key="notification_tracker.id", index=True) # The specific link this deferral targets. On drain we re-fetch by ID; if # the link was disabled or removed in the meantime we drop with a # ``deferred_then_dropped`` log row instead of dispatching to nothing. link_id: int = Field( foreign_key="notification_tracker_target.id", index=True, ) # The event_log row written when the event was first detected. The drain # writes a follow-up event_log row referencing this id so the dashboard # can show "delivered at HH:MM, originally detected at HH:MM". # # ``ondelete="SET NULL"`` matters because the daily ``_cleanup_old_events`` # job hard-deletes event_log rows past the retention horizon. Without # SET NULL, an old pending DeferredDispatch row referencing an aging-out # event_log row would either (a) prevent the delete with an FK violation # under SQLite's enforced foreign_keys PRAGMA, or (b) leave a dangling # reference on engines that don't enforce. event_log_id: int | None = Field( default=None, sa_column=Column( "event_log_id", ForeignKey("event_log.id", ondelete="SET NULL"), nullable=True, index=True, ), ) event_type: str = Field(index=True) collection_id: str = Field(default="", index=True) # ``dataclasses.asdict(ServiceEvent)`` with datetime/enum normalisation — # round-tripped via the helpers in ``services.deferred_dispatch``. event_payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) fire_at: datetime = Field(index=True) # ``pending`` until the drain runs; then ``fired``, ``dropped`` (link # gone / event-type disabled after defer), or ``cancelled`` (coalesced # away by a counter-event). status: str = Field(default="pending", index=True) fired_at: datetime | None = Field(default=None) created_at: 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) # Owner. Indexed for the dashboard events query. Nullable only because # historical rows (pre-user_id column) may have no owner; new rows always # set this directly. SET NULL on user delete preserves the audit trail # while letting the user record itself be removed. user_id: int | None = Field( default=None, sa_column=Column( "user_id", ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True, ), ) # Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id tracker_id: int | None = Field( default=None, foreign_key="notification_tracker.id", index=True, sa_column_kwargs={"name": "notification_tracker_id"}, ) tracker_name: str = Field(default="") # Links an event back to an Action when the event was emitted by the # action runner (``event_type`` starts with ``action_``). Null for # notification-tracker events. action_id: int | None = Field( default=None, foreign_key="action.id", index=True, ) action_name: str = Field(default="") # Bot command provenance. Populated when ``event_type`` starts with # ``command_`` so the dashboard can render command activity alongside # tracker and action events. NULL for non-command rows. command_tracker_id: int | None = Field( default=None, foreign_key="command_tracker.id", index=True, ) command_tracker_name: str = Field(default="") telegram_bot_id: int | None = Field( default=None, foreign_key="telegram_bot.id", index=True, ) bot_name: str = Field(default="") # FK to service_provider with SET NULL so deleting a provider leaves # historical event_log rows intact (provider_name preserves the label # for display). The FK only takes effect on freshly created tables — # SQLite cannot ALTER a constraint into an existing table without a # rebuild, so application code in api/providers.delete_provider also # nulls these explicitly. See migrate_eventlog_provider_fk. provider_id: int | None = Field( default=None, sa_column=Column( "provider_id", ForeignKey("service_provider.id", ondelete="SET NULL"), nullable=True, index=True, ), ) provider_name: str = Field(default="") event_type: str = Field(index=True) collection_id: str collection_name: str assets_count: int = Field(default=0) details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) created_at: datetime = Field(default_factory=_utcnow) class Action(SQLModel, table=True): """A scheduled action that mutates an external service.""" __tablename__ = "action" id: int | None = Field(default=None, primary_key=True) user_id: int = Field( sa_column=Column( "user_id", ForeignKey("user.id", ondelete="CASCADE"), nullable=False, ), ) provider_id: int = Field(foreign_key="service_provider.id") name: str icon: str = Field(default="") action_type: str # e.g. "auto_organize" config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) schedule_type: str = Field(default="interval") # "interval" or "cron" schedule_interval: int = Field(default=3600) # seconds schedule_cron: str = Field(default="") enabled: bool = Field(default=False) # default disabled for safety last_run_at: datetime | None = Field(default=None) last_run_status: str = Field(default="") # "success", "partial", "failed", "" created_at: datetime = Field(default_factory=_utcnow) class ActionRule(SQLModel, table=True): """One rule within an Action. Executed in order.""" __tablename__ = "action_rule" id: int | None = Field(default=None, primary_key=True) action_id: int = Field(foreign_key="action.id", index=True) name: str = Field(default="") rule_config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) enabled: bool = Field(default=True) order: int = Field(default=0) created_at: datetime = Field(default_factory=_utcnow) class ActionExecution(SQLModel, table=True): """Log of an action execution (scheduled, manual, or dry-run).""" __tablename__ = "action_execution" id: int | None = Field(default=None, primary_key=True) action_id: int = Field(foreign_key="action.id", index=True) started_at: datetime = Field(default_factory=_utcnow) finished_at: datetime | None = Field(default=None) status: str = Field(default="running") # "running", "success", "partial", "failed" rules_processed: int = Field(default=0) rules_succeeded: int = Field(default=0) rules_failed: int = Field(default=0) total_items_affected: int = Field(default=0) summary: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) error: str = Field(default="") trigger: str = Field(default="scheduled") # "scheduled", "manual", "dry_run" class WebhookPayloadLog(SQLModel, table=True): """Log of incoming webhook payloads for debugging and replay.""" __tablename__ = "webhook_payload_log" id: int | None = Field(default=None, primary_key=True) provider_id: int = Field(foreign_key="service_provider.id", index=True) method: str = Field(default="POST") headers: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) body: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) status: str = Field(default="matched") # "matched" | "unmatched" | "error" extracted_fields: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) error_message: str = Field(default="") created_at: datetime = Field(default_factory=_utcnow) class AppSetting(SQLModel, table=True): """Key-value app-level settings (admin-configurable).""" __tablename__ = "app_setting" key: str = Field(primary_key=True) value: str = Field(default="")