diff --git a/CHANGELOG.md b/CHANGELOG.md index 1446b7e57..81c960a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ All notable changes to Bambuddy will be documented in this file. - **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. +- **By-tag spool lookup, readable with a Manage-Inventory API key (#1700 closing #1663, reported + contributed by @bambuman)** — Companion to the QR-code-API-key flow below: gives @bambuman's BambuMan NFC inventory app — and any future scanner-driven Bambuddy integration — a way to dedupe a spool scan with a single, narrowly-scoped API key. **New endpoint:** `GET /inventory/spools/by-tag?tray_uuid=…&tag_uid=…&include_archived=false`. `tray_uuid` is the primary identifier (it's the same 32-char hex the AMS reports over MQTT, so the scan can match a spool that's already linked to the printer), `tag_uid` is the fallback. At least one must be supplied (400 otherwise); 404 when nothing matches. Both values are passed through `normalize_tray_uuid` / `normalize_tag_uid` from `backend/app/utils/tag_normalization.py` — lowercase / colon / dash separators all match the stored uppercase hex, mirroring the existing `link_tag` route's `func.upper(column) == value` comparison so SQLite and Postgres behave identically. Archived spools are excluded by default, opt in via `include_archived=true`. **Why this isn't on the existing `/inventory/spools` list endpoint:** that one is purely advisory — it returns every spool the caller is allowed to see, no auth narrowing possible. The contributor's NFC app would have had to pull the whole inventory to check whether a freshly-scanned tag already existed, which both required the broader **Read Status** scope (an API key with **Manage Inventory** alone — the documented kiosk/inventory-write scope — couldn't list spools) and grew O(n) with the user's spool count. By-tag lookup is O(1) and the narrower scope rule below means the Manage-Inventory key the app already needs to *create* a spool is also enough to *check whether one exists* before creating. **Scope shape (per-endpoint, NOT a global mapping change):** `RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE)` — INVENTORY_READ is satisfied by `can_read_status` (read-status keys), INVENTORY_UPDATE by `can_manage_inventory` (manage-inventory keys), and `_check_apikey_permissions(..., require_any=True)` enforces that at least one mapped flag is set (the GHSA-r2qv-8222-hqg3 fail-closed rule). Listing all spools (`/inventory/spools`) and fetching by id (`/inventory/spools/{id}`) still require **Read Status** unchanged — only this one endpoint accepts either scope. The first iteration of the PR widened the global `_APIKEY_SCOPE_BY_PERMISSION` to a tuple, which would have promoted ~21 inventory-read endpoints to also accept manage-inventory keys; review caught that the global shape was wider than the ask and the contributor revised to the per-endpoint dependency. The drift-detection RBAC scope-introspection tests stay untouched because the global table didn't change. **Route ordering:** the new `/spools/by-tag` registers at `inventory.py:1184` *before* the existing `/spools/{spool_id}` at `:1227`, so FastAPI's first-match wins and the literal `by-tag` path never collides with the `int spool_id` route (pinned by `test_does_not_collide_with_spool_id_route`). **Tests:** 13 integration cases in `backend/tests/integration/test_spool_by_tag_lookup.py` — match by tray_uuid, match by tag_uid, normalisation of messy input, tray_uuid-preferred-when-both-given, tray_uuid-miss falls through to tag_uid (not 404), no-id → 400, non-hex → 400, no-match → 404, archived-excluded-by-default + include-archived opt-in, route-collision regression, plus three API-key scope cases that pin the new dependency (manage-inventory key reads, read-status key reads, key without either inventory scope gets 403). 13/13 green plus the 48 existing route-auth-coverage + RBAC tests still green (the `require_` substring pattern already catches `require_any_permission_if_auth_enabled..checker` — no allowlist edit needed). Ruff clean. **Companion docs (maziggy/bambuddy-wiki#42):** `docs/reference/api.md` gains a new **Spool Inventory** section documenting the endpoint contract; `docs/features/api-keys.md` adds the by-tag row to the Common Endpoints table and a "Manage Inventory keys can look up spools by tag" note. No DB migration, no schema change, no frontend change. + - **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.