diff --git a/apps/web/src/app/discover/_components/DiscoverLoadMore.tsx b/apps/web/src/app/discover/_components/DiscoverLoadMore.tsx index 8a4c2fcf..cbd455cd 100644 --- a/apps/web/src/app/discover/_components/DiscoverLoadMore.tsx +++ b/apps/web/src/app/discover/_components/DiscoverLoadMore.tsx @@ -206,6 +206,42 @@ function ClientGridAppendix({ a.appendChild(dn); } + // Supporter chip — load-more path is imperative DOM, so we + // build a simplified pill here rather than mounting the + // React `` component. Tier-specific palette + // and accessible-label work appears on the server-rendered + // first page; the load-more chip uses the standard accent + // palette for all tiers. Cleaner than maintaining two + // tier-palette mappings (one TS, one imperative DOM string). + if (p.supporter) { + const wrap = document.createElement('div'); + wrap.style.marginTop = '2px'; + const chip = document.createElement('span'); + chip.className = 'mono'; + Object.assign(chip.style, { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '2px 8px', + fontSize: '11px', + fontWeight: '500', + color: 'var(--accent)', + background: + 'color-mix(in oklab, var(--accent) 12%, transparent)', + border: '1px solid var(--accent)', + borderRadius: 'var(--r-pill)', + }); + const label = + p.supporter.state === 'lapsed' + ? 'Supporter (lapsed)' + : 'Supporter'; + chip.textContent = p.supporter.name_plate + ? `${label} · ${p.supporter.name_plate}` + : label; + wrap.appendChild(chip); + a.appendChild(wrap); + } + const rel = formatRelative(p.last_active_at); if (rel) { const t = document.createElement('div'); diff --git a/apps/web/src/app/discover/page.tsx b/apps/web/src/app/discover/page.tsx index fddb9f97..b05e2609 100644 --- a/apps/web/src/app/discover/page.tsx +++ b/apps/web/src/app/discover/page.tsx @@ -24,6 +24,7 @@ import { type DiscoverProfilesResponse, } from '@/lib/api'; import { logger } from '@/lib/logger'; +import { SupporterChip } from '@/components/SupporterChip'; import { DiscoverLoadMore } from './_components/DiscoverLoadMore'; // Default request size on the initial render. Mirrors the server- @@ -189,6 +190,11 @@ export default async function DiscoverPage() { {p.display_name} ) : null} + {p.supporter ? ( +
+ +
+ ) : null} {relative ? (
, + /// Supporter chip data — same public-safe projection used by the + /// summary endpoint (see `sharing_routes::PublicSupporterInfo`). + /// `None` for non-supporters; present (with tier + plate) for + /// `active`/`lapsed` rows. Bulk-fetched alongside the profile + /// list so the discover page renders chips without N+1 queries. + pub supporter: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -242,6 +250,12 @@ impl DiscoverStore for PostgresDiscoverStore { display_name, joined_at: Some(created_at.to_rfc3339()), last_active_at: last_active_at.map(|t| t.to_rfc3339()), + // Supporter info is bulk-fetched by the handler + // AFTER the listing query returns — keeps this + // store method focused on the user/profile join + // and avoids growing this SQL with a third + // optional source. + supporter: None, }, ) .collect()) @@ -274,6 +288,7 @@ pub fn routes(store: Arc) -> Router { pub async fn list_discover_profiles( State(store): State>, Extension(spicedb): Extension>>, + Extension(supporters): Extension>, Query(q): Query, ) -> Response { let Some(client) = spicedb.as_ref() else { @@ -360,6 +375,49 @@ pub async fn list_discover_profiles( None }; + // Bulk-fetch supporter chip data for everyone on this page in a + // single SQL round-trip. Failing soft means a transient supporter- + // store error degrades to "no chips on the list" rather than + // 5xx'ing the whole discover page (chips are decorative; the + // listing is the load-bearing payload). Logged as a warn so ops + // sees the degradation. + if !rows.is_empty() { + let handles: Vec = rows.iter().map(|p| p.handle.clone()).collect(); + match supporters.get_many_public_by_handle(&handles).await { + Ok(supporter_map) => { + for profile in rows.iter_mut() { + let key = profile.handle.to_ascii_lowercase(); + if let Some(status) = supporter_map.get(&key) { + // Same projection logic as + // sharing_routes::supporter_to_public; we + // can't reuse that helper directly because + // it's a private fn in sharing_routes (and + // adding a pub re-export for one call site + // is more coupling than the duplication). + let info = match status.state { + SupporterState::Active | SupporterState::Lapsed => { + Some(PublicSupporterInfo { + state: status.state.as_str().to_string(), + current_tier_key: status.current_tier_key.clone(), + name_plate: status.name_plate.clone(), + }) + } + SupporterState::None => None, + }; + profile.supporter = info; + } + } + } + Err(e) => { + tracing::warn!( + error = %e, + count = handles.len(), + "discover supporter bulk fetch failed; chips will be absent" + ); + } + } + } + ( StatusCode::OK, Json(DiscoverProfilesResponse { @@ -466,6 +524,10 @@ mod tests { display_name: Some(format!("{handle} Display")), joined_at: Some("2026-01-01T00:00:00+00:00".to_owned()), last_active_at: None, + // No supporter data in these tests — the listing-store + // tests target the SQL-side filter/sort, not the chip + // enrichment which is a separate handler step. + supporter: None, } } diff --git a/crates/starstats-server/src/sharing_routes.rs b/crates/starstats-server/src/sharing_routes.rs index 7f8c6154..e1a9cd00 100644 --- a/crates/starstats-server/src/sharing_routes.rs +++ b/crates/starstats-server/src/sharing_routes.rs @@ -342,7 +342,7 @@ pub struct PublicSummaryResponse { /// Public-safe supporter projection for the profile chip. Mirrors /// the minimum fields the web `` needs and nothing /// else. -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PublicSupporterInfo { /// Either `active` or `lapsed`. `none` is never serialised here; /// the outer `supporter` field is `None` instead. diff --git a/crates/starstats-server/src/supporters.rs b/crates/starstats-server/src/supporters.rs index c2802b12..3c2bf408 100644 --- a/crates/starstats-server/src/supporters.rs +++ b/crates/starstats-server/src/supporters.rs @@ -128,6 +128,26 @@ pub trait SupporterStore: Send + Sync + 'static { handle: &str, ) -> Result, SupporterError>; + /// Bulk variant of [`Self::get_by_handle_public`] for the + /// `/v1/discover/profiles` listing. Returns a map keyed by the + /// LOWERCASED handle so callers can `HashMap::get` against + /// `handle.to_ascii_lowercase()` without re-allocating per row. + /// + /// Only `active` + `lapsed` supporters are surfaced — non-supporter + /// users simply have no entry in the returned map. + /// + /// Critical: this MUST be a single SQL round-trip with + /// `WHERE LOWER(claimed_handle) = ANY($1)`. Naïve N round-trips + /// (one per profile) would scale linearly with `DEFAULT_LIMIT` + /// (50) — death by latency for what's already a SpiceDB + + /// users-table query. + /// + /// Empty input returns an empty map without a DB round-trip. + async fn get_many_public_by_handle( + &self, + handles: &[String], + ) -> Result, SupporterError>; + /// Flip the user's state to `active` and record a payment. The /// webhook handler calls this when an `ORDER_COMPLETED` event /// lands. Idempotent: replaying the same payment is a no-op @@ -279,6 +299,93 @@ impl SupporterStore for PostgresSupporterStore { )) } + async fn get_many_public_by_handle( + &self, + handles: &[String], + ) -> Result, SupporterError> { + use std::collections::HashMap; + if handles.is_empty() { + return Ok(HashMap::new()); + } + // Normalise to lowercase once on the wire so the SQL `= ANY($1)` + // matches against `LOWER(claimed_handle)`. Mirrors the same + // pattern used by `discover_routes::list_public_profiles_filtered`. + let normalized: Vec = handles.iter().map(|h| h.to_ascii_lowercase()).collect(); + + // Same JOIN shape as `get_by_handle_public`, just keyed by an + // array. `DISTINCT ON (s.user_id)` collapses the LATERAL + // tier-lookup result to one row per user (defensive — without + // DISTINCT a future change that joined a 1-to-many side + // would silently inflate the response). The supporter + + // user pair are inherently 1-to-1 since `user_id` is the + // supporter_status PK, so DISTINCT is currently a no-op but + // it cheaply hardens the bulk path against future drift. + let rows: Vec<( + String, + Uuid, + String, + Option, + Option>, + Option>, + Option>, + Option>, + DateTime, + Option, + )> = sqlx::query_as( + "SELECT DISTINCT ON (s.user_id) + LOWER(u.claimed_handle) AS handle_key, + s.user_id, s.state, s.name_plate, + s.became_supporter_at, s.last_payment_at, + s.grace_until, s.cancelled_at, s.updated_at, + o.tier_key + FROM users u + INNER JOIN supporter_status s ON s.user_id = u.id + LEFT JOIN LATERAL ( + SELECT tier_key + FROM revolut_orders + WHERE user_id = s.user_id AND state = 'completed' + ORDER BY completed_at DESC NULLS LAST, created_at DESC + LIMIT 1 + ) o ON true + WHERE LOWER(u.claimed_handle) = ANY($1) + AND s.state IN ('active', 'lapsed')", + ) + .bind(&normalized) + .fetch_all(&self.pool) + .await?; + + let mut map = HashMap::with_capacity(rows.len()); + for ( + handle_key, + user_id, + state, + name_plate, + became, + last_pay, + grace, + cancelled, + updated, + tier_key, + ) in rows + { + map.insert( + handle_key, + SupporterStatus { + user_id, + state: SupporterState::parse(&state).unwrap_or(SupporterState::None), + name_plate, + became_supporter_at: became, + last_payment_at: last_pay, + grace_until: grace, + cancelled_at: cancelled, + updated_at: updated, + current_tier_key: tier_key, + }, + ); + } + Ok(map) + } + async fn mark_payment_received( &self, user_id: Uuid, @@ -379,6 +486,33 @@ pub mod test_support { .filter(|s| matches!(s.state, SupporterState::Active | SupporterState::Lapsed))) } + async fn get_many_public_by_handle( + &self, + handles: &[String], + ) -> Result, SupporterError> { + if handles.is_empty() { + return Ok(HashMap::new()); + } + let bindings = self.handles.lock().expect("supporter memstore poisoned"); + let rows = self.rows.lock().expect("supporter memstore poisoned"); + let mut out = HashMap::new(); + for h in handles { + let key = h.to_ascii_lowercase(); + let Some(uid) = bindings.get(&key).copied() else { + continue; + }; + if let Some(status) = rows.get(&uid).cloned() { + if matches!( + status.state, + SupporterState::Active | SupporterState::Lapsed + ) { + out.insert(key, status); + } + } + } + Ok(out) + } + async fn mark_payment_received( &self, user_id: Uuid, @@ -541,6 +675,84 @@ mod tests { assert_eq!(result.name_plate.as_deref(), Some("Caelum")); } + #[tokio::test] + async fn get_many_public_by_handle_returns_empty_for_empty_input() { + let store = MemorySupporterStore::default(); + let result = store + .get_many_public_by_handle(&[]) + .await + .expect("get_many"); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn get_many_public_by_handle_skips_unknown_and_unbound_handles() { + let store = MemorySupporterStore::default(); + let known_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_id: known_id, + state: SupporterState::Active, + name_plate: Some("Caelum".into()), + became_supporter_at: Some(Utc::now()), + last_payment_at: Some(Utc::now()), + grace_until: None, + cancelled_at: None, + updated_at: Utc::now(), + current_tier_key: Some("standard".into()), + }); + store.bind_handle("Caelum", known_id); + // Throw in another seeded row with no handle binding to make + // sure we don't fall through to user_id-keyed iteration. + let orphan_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_id: orphan_id, + state: SupporterState::Active, + name_plate: None, + became_supporter_at: Some(Utc::now()), + last_payment_at: Some(Utc::now()), + grace_until: None, + cancelled_at: None, + updated_at: Utc::now(), + current_tier_key: Some("coffee".into()), + }); + + let result = store + .get_many_public_by_handle(&[ + "caelum".to_string(), + "Stranger".to_string(), + "OrphanWithoutHandle".to_string(), + ]) + .await + .expect("get_many"); + assert_eq!(result.len(), 1); + let row = result.get("caelum").expect("caelum present"); + assert_eq!(row.current_tier_key.as_deref(), Some("standard")); + assert!(!result.contains_key("stranger")); + } + + #[tokio::test] + async fn get_many_public_by_handle_filters_none_state() { + let store = MemorySupporterStore::default(); + let quiet_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_id: quiet_id, + state: SupporterState::None, + name_plate: None, + became_supporter_at: None, + last_payment_at: None, + grace_until: None, + cancelled_at: None, + updated_at: Utc::now(), + current_tier_key: None, + }); + store.bind_handle("Quiet", quiet_id); + let result = store + .get_many_public_by_handle(&["Quiet".to_string()]) + .await + .expect("get_many"); + assert!(result.is_empty(), "none-state users must not surface"); + } + #[tokio::test] async fn get_by_handle_public_filters_none_state() { // `state = none` users should NOT surface in the public diff --git a/packages/api-client-ts/src/generated/schema.ts b/packages/api-client-ts/src/generated/schema.ts index 04604112..d87a180a 100644 --- a/packages/api-client-ts/src/generated/schema.ts +++ b/packages/api-client-ts/src/generated/schema.ts @@ -2661,6 +2661,7 @@ export interface components { * NULL for lines that parsed structurally but lacked a stamp). */ last_active_at?: string | null; + supporter?: null | components["schemas"]["PublicSupporterInfo"]; }; DiscoverProfilesResponse: { /**