"""Sponsor-prompt trigger evaluator and dismiss handler. Drives the in-app "support keeps Bambuddy independent" toast. Trigger families fire at milestones the user has earned (prints, archives, filament cost, anniversary) plus a soft version-update nudge after a major upgrade. A 14-day cooldown applies across ALL families: if any toast fired in the last 14 days, no new toast fires. Each individual milestone is shown at most once per user (or once per install in auth-disabled mode); version-update is the exception — it re-arms every time the running version is newer than the one last acknowledged. """ from __future__ import annotations import json import logging from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.config import APP_VERSION from backend.app.models.archive import PrintArchive from backend.app.models.print_log import PrintLogEntry from backend.app.models.sponsor_toast_state import SponsorToastState from backend.app.models.user import User logger = logging.getLogger(__name__) COOLDOWN_DAYS = 14 PRINT_MILESTONES = (10, 25, 100, 500, 1000, 2500, 5000) COST_MILESTONES = (25, 50, 100, 500, 1000) ARCHIVE_MILESTONES = (5, 10, 50, 250, 1000) ANNIVERSARY_YEARS = 1 @dataclass class Trigger: """Evaluated trigger result returned to the frontend.""" milestone: str # e.g. "prints-500", "anniversary-1", "version-update" family: str # "prints" | "cost" | "archives" | "anniversary" | "version-update" threshold: int | None = None payload: dict[str, Any] = field(default_factory=dict) # --------------------------------------------------------------------------- # State helpers # --------------------------------------------------------------------------- async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState: """Fetch the state row for this user (or the install-default NULL row). Creates the row lazily on first access so the migration doesn't need to seed anything. """ if user_id is None: stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None)) else: stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id) result = await db.execute(stmt) state = result.scalar_one_or_none() if state is None: state = SponsorToastState(user_id=user_id, milestones_seen="[]") db.add(state) await db.flush() return state def _within_cooldown(state: SponsorToastState) -> bool: if state.last_shown_at is None: return False cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS) last = state.last_shown_at if last.tzinfo is None: last = last.replace(tzinfo=timezone.utc) return last >= cutoff def _seen_milestones(state: SponsorToastState) -> set[str]: try: raw = json.loads(state.milestones_seen or "[]") return set(raw) if isinstance(raw, list) else set() except (json.JSONDecodeError, TypeError): logger.warning( "sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting", state.user_id, ) return set() # --------------------------------------------------------------------------- # Per-family checks # --------------------------------------------------------------------------- def _user_filter(column, user_id: int | None): return column.is_(None) if user_id is None else column == user_id async def _check_anniversary( db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState ) -> Trigger | None: milestone = f"anniversary-{ANNIVERSARY_YEARS}" if milestone in seen: return None if user_id is None: # Install-anchor = earliest users.created_at (the first admin row). result = await db.execute(select(func.min(User.created_at))) anchor = result.scalar() else: result = await db.execute(select(User.created_at).where(User.id == user_id)) anchor = result.scalar() if anchor is None: return None if anchor.tzinfo is None: anchor = anchor.replace(tzinfo=timezone.utc) if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS): return None return Trigger(milestone=milestone, family="anniversary") async def _check_prints( db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState ) -> Trigger | None: stmt = ( select(func.count()) .select_from(PrintLogEntry) .where( PrintLogEntry.status == "completed", _user_filter(PrintLogEntry.created_by_id, user_id), ) ) completed = (await db.execute(stmt)).scalar() or 0 # Pick the LARGEST milestone the user has crossed but not yet seen. for threshold in sorted(PRINT_MILESTONES, reverse=True): key = f"prints-{threshold}" if completed >= threshold and key not in seen: return Trigger( milestone=key, family="prints", threshold=threshold, payload={"count": completed}, ) return None async def _check_archives( db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState ) -> Trigger | None: stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id)) archived = (await db.execute(stmt)).scalar() or 0 for threshold in sorted(ARCHIVE_MILESTONES, reverse=True): key = f"archives-{threshold}" if archived >= threshold and key not in seen: return Trigger( milestone=key, family="archives", threshold=threshold, payload={"count": archived}, ) return None async def _check_cost( db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState ) -> Trigger | None: stmt = ( select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0)) .select_from(PrintLogEntry) .where(_user_filter(PrintLogEntry.created_by_id, user_id)) ) total = float((await db.execute(stmt)).scalar() or 0) for threshold in sorted(COST_MILESTONES, reverse=True): key = f"cost-{threshold}" if total >= threshold and key not in seen: return Trigger( milestone=key, family="cost", threshold=threshold, payload={"total": round(total, 2)}, ) return None async def _check_version_update( _db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState ) -> Trigger | None: # version-update is NOT in milestones_seen — it has its own state column # so it can re-fire on each major bump. if not APP_VERSION: return None last = state.last_seen_version if last is None: # First-ever read; treat as already-acknowledged so we don't toast # immediately on a brand-new install. Persist current version silently. state.last_seen_version = APP_VERSION return None if last == APP_VERSION: return None return Trigger( milestone="version-update", family="version-update", payload={"from": last, "to": APP_VERSION}, ) # Priority order: most emotional / earned first; version-update is the soft fallback. _CHECKS = ( _check_anniversary, _check_prints, _check_archives, _check_cost, _check_version_update, ) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None: """Return the next eligible sponsor-toast trigger, or None.""" state = await _get_or_create_state(db, user_id) if _within_cooldown(state): return None seen = _seen_milestones(state) for check in _CHECKS: trigger = await check(db, user_id, seen, state) if trigger is not None: return trigger # No triggers eligible — still commit any in-progress state changes # (e.g. version-update's first-touch persistence). await db.flush() return None async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None: """Mark a milestone as shown (sets cooldown anchor + records seen).""" state = await _get_or_create_state(db, user_id) if milestone == "version-update": # Re-armable: just update last_seen_version, don't add to seen-list. state.last_seen_version = APP_VERSION else: seen = _seen_milestones(state) if milestone not in seen: seen.add(milestone) state.milestones_seen = json.dumps(sorted(seen)) state.last_shown_at = datetime.now(timezone.utc) await db.flush()