mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
0d4b9d4e gives passive every-visit visibility on one page; this adds
opt-out-able active visibility at moments where the user has just
earned something with Bambuddy.
Five trigger families with a 14-day cross-family cooldown: prints
(100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
energy), archives (50/250/1000), anniversary (1 year), version-update
(re-armable on each major bump). New sponsor_toast_state table with
nullable user_id so auth-disabled installs get the same trigger logic
through one code path (NULL-keyed install-default row).
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""API routes for the in-app sponsor toast."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.sponsor_prompt import (
|
||||
SponsorPromptCheckResponse,
|
||||
SponsorPromptDismissRequest,
|
||||
)
|
||||
from backend.app.services import sponsor_prompt as service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sponsor-prompt", tags=["sponsor-prompt"])
|
||||
|
||||
|
||||
def _user_id(current_user: User | None) -> int | None:
|
||||
return current_user.id if current_user is not None else None
|
||||
|
||||
|
||||
@router.get("/check", response_model=SponsorPromptCheckResponse)
|
||||
async def check_sponsor_prompt(
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the next eligible sponsor-toast trigger, or `{show: false}`."""
|
||||
trigger = await service.evaluate(db, _user_id(current_user))
|
||||
await db.commit()
|
||||
if trigger is None:
|
||||
return SponsorPromptCheckResponse(show=False)
|
||||
return SponsorPromptCheckResponse(
|
||||
show=True,
|
||||
milestone=trigger.milestone,
|
||||
family=trigger.family,
|
||||
threshold=trigger.threshold,
|
||||
payload=trigger.payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dismiss", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def dismiss_sponsor_prompt(
|
||||
data: SponsorPromptDismissRequest,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Anchor the 14-day cooldown and record the milestone as shown."""
|
||||
await service.dismiss(db, _user_id(current_user), data.milestone)
|
||||
await db.commit()
|
||||
return None
|
||||
@@ -57,6 +57,7 @@ from backend.app.api.routes import (
|
||||
slice_jobs,
|
||||
slicer_presets,
|
||||
smart_plugs,
|
||||
sponsor_prompt,
|
||||
spoolbuddy,
|
||||
spoolman,
|
||||
spoolman_inventory,
|
||||
@@ -6525,6 +6526,7 @@ app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(updates.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(camera.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(external_links.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -25,6 +25,7 @@ from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
||||
from backend.app.models.sponsor_toast_state import SponsorToastState
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spool_catalog import SpoolCatalogEntry
|
||||
@@ -75,6 +76,7 @@ __all__ = [
|
||||
"SpoolUsageHistory",
|
||||
"ColorCatalogEntry",
|
||||
"SpoolBuddyDevice",
|
||||
"SponsorToastState",
|
||||
"UserEmailPreference",
|
||||
"UserOTPCode",
|
||||
"UserTOTP",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Per-user (or install-default) state for the sponsor-prompt toast.
|
||||
|
||||
A single row stores which sponsor-toast milestones have already fired for a
|
||||
given user, when the most recent toast was shown (for the 14-day cooldown),
|
||||
and the app version last seen so we can fire the "version-update" trigger
|
||||
exactly once per major bump.
|
||||
|
||||
``user_id`` is nullable: in auth-disabled installs (no user concept), the
|
||||
service stores everything against a single NULL-keyed row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class SponsorToastState(Base):
|
||||
__tablename__ = "sponsor_toast_state"
|
||||
__table_args__ = (UniqueConstraint("user_id", name="uq_sponsor_toast_state_user_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_shown_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# JSON-serialised list[str] of milestone keys already fired (e.g. ["prints-100", "cost-100"]).
|
||||
# Stored as Text for SQLite/Postgres uniformity; the service serialises with json.dumps.
|
||||
milestones_seen: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
last_seen_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Pydantic schemas for the sponsor-prompt API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SponsorPromptCheckResponse(BaseModel):
|
||||
"""Result of GET /sponsor-prompt/check."""
|
||||
|
||||
show: bool
|
||||
milestone: str | None = None
|
||||
family: str | None = None
|
||||
threshold: int | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SponsorPromptDismissRequest(BaseModel):
|
||||
"""Body of POST /sponsor-prompt/dismiss."""
|
||||
|
||||
milestone: str
|
||||
@@ -0,0 +1,256 @@
|
||||
"""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 = (100, 500, 1000, 2500, 5000)
|
||||
COST_MILESTONES = (100, 500, 1000)
|
||||
ARCHIVE_MILESTONES = (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()
|
||||
Reference in New Issue
Block a user