diff --git a/apps/web/src/app/u/[handle]/page.tsx b/apps/web/src/app/u/[handle]/page.tsx index 60f7d3d..69d7d27 100644 --- a/apps/web/src/app/u/[handle]/page.tsx +++ b/apps/web/src/app/u/[handle]/page.tsx @@ -258,19 +258,32 @@ export default async function PublicProfilePage(props: PageProps) { } } - // Self-path supporter chip. We only fetch + render it for the owner - // viewing their own page (kind === 'self') because the supporter - // data isn't currently exposed on the public/friend summary - // endpoints — extending PublicSummaryResponse with supporter info - // is a follow-up. Fail-soft to null on any error so the rest of - // the profile keeps rendering. - let supporter: SupporterStatusDto | null = null; + // Supporter chip: + // - self path: fetch the full SupporterStatusDto via /v1/me/supporter + // (carries grace_until + timestamps the chip ignores; same shape + // SupporterChip already takes). + // - public / shared paths: read the public projection that + // `PublicSummaryResponse.supporter` now carries (state + tier + + // name_plate only — see `PublicSupporterInfo` server-side). The + // two shapes overlap on the three fields SupporterChip needs, so + // we feed either through the same `status` prop. + // Fail-soft to null on any error so the rest of the profile keeps + // rendering. + type ChipStatus = Pick< + SupporterStatusDto, + 'state' | 'name_plate' | 'current_tier_key' + >; + let chipStatus: ChipStatus | null = null; if (view.kind === 'self' && token) { try { - supporter = await getSupporterStatus(token); + chipStatus = await getSupporterStatus(token); } catch (e) { logger.warn({ err: e }, 'self supporter status fetch failed'); } + } else if (view.kind === 'public' || view.kind === 'shared') { + // PublicSummaryResponse.supporter is `PublicSupporterInfo | null`; + // structurally identical to the SupporterChip status shape. + chipStatus = data.supporter ?? null; } return ( @@ -331,7 +344,7 @@ export default async function PublicProfilePage(props: PageProps) { {profile && ( RSI verified )} - + {/* Sharing CTAs — context-sensitive deep links into /sharing. diff --git a/crates/starstats-server/src/main.rs b/crates/starstats-server/src/main.rs index dcd1bc7..c545c58 100644 --- a/crates/starstats-server/src/main.rs +++ b/crates/starstats-server/src/main.rs @@ -451,6 +451,10 @@ async fn main() -> anyhow::Result<()> { let share_metadata_dyn: Arc = share_metadata.clone(); let share_reports_dyn: Arc = share_reports.clone(); + // Threaded through the request layer so sharing/discover handlers + // can look up supporter chip info by handle. Same dyn-cast pattern + // as share_metadata_dyn etc. + let supporter_store_dyn: Arc = supporter_store.clone(); // Roadmap pipeline (Phases 1-8). The store is always constructed // so the public read API works even without GitHub credentials — @@ -636,6 +640,7 @@ async fn main() -> anyhow::Result<()> { .layer(Extension(health_pool)) .layer(Extension(spicedb)) .layer(Extension(public_access_checker)) + .layer(Extension(supporter_store_dyn)) .layer(Extension(share_metadata_dyn)) .layer(Extension(share_reports_dyn)) .layer(Extension(admin_parser_submissions_store)) diff --git a/crates/starstats-server/src/openapi.rs b/crates/starstats-server/src/openapi.rs index 622e850..6954006 100644 --- a/crates/starstats-server/src/openapi.rs +++ b/crates/starstats-server/src/openapi.rs @@ -484,6 +484,7 @@ impl Modify for SecurityAddon { sharing_routes::SharedWithMeEntry, sharing_routes::ListSharedWithMeResponse, sharing_routes::PublicSummaryResponse, + sharing_routes::PublicSupporterInfo, sharing_routes::PublicTypeCount, sharing_routes::PublicTimelineResponse, sharing_routes::PublicTimelineBucket, diff --git a/crates/starstats-server/src/sharing_routes.rs b/crates/starstats-server/src/sharing_routes.rs index 8bca4a2..7f8c615 100644 --- a/crates/starstats-server/src/sharing_routes.rs +++ b/crates/starstats-server/src/sharing_routes.rs @@ -29,6 +29,7 @@ use crate::share_reports::{ RATE_LIMIT_PER_WINDOW, }; use crate::spicedb::{ObjectRef, SpicedbClient}; +use crate::supporters::{SupporterState, SupporterStatus, SupporterStore}; use crate::users::{PostgresUserStore, UserStore}; use crate::validation::{build_timeline_buckets, resolve_timeline_days, validate_handle}; use axum::{ @@ -326,6 +327,33 @@ pub struct PublicSummaryResponse { pub claimed_handle: String, pub total: u64, pub by_type: Vec, + /// Supporter chip data for public + friend views. `None` when + /// the user has never donated (state = 'none' or no row) — the + /// web `` renders nothing in that case. Present + /// for both `active` and `lapsed` per the "pill stays — + /// recognition is permanent" design (see + /// `docs/REVOLUT-INTEGRATION-PLAN.md`). Only fields safe to + /// expose publicly: state + tier + plate. We deliberately + /// withhold `grace_until` / payment timestamps to limit + /// fingerprinting from a stranger. + pub supporter: Option, +} + +/// Public-safe supporter projection for the profile chip. Mirrors +/// the minimum fields the web `` needs and nothing +/// else. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PublicSupporterInfo { + /// Either `active` or `lapsed`. `none` is never serialised here; + /// the outer `supporter` field is `None` instead. + pub state: String, + /// `coffee` / `standard` / `generous`. `None` when no completed + /// order exists yet (should not happen for live data but the + /// frontend renders a tier-less fallback rather than crashing). + pub current_tier_key: Option, + /// Optional 28-char display string; `None` when the user has not + /// set one. + pub name_plate: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -1541,8 +1569,27 @@ where } } -async fn render_summary(query: &Q, handle: &str) -> Response { - render_summary_scoped(query, handle, None).await +async fn render_summary( + query: &Q, + supporters: &dyn SupporterStore, + handle: &str, +) -> Response { + render_summary_scoped(query, supporters, handle, None).await +} + +/// Map a `SupporterStatus` (raw store shape, full field set) to a +/// `PublicSupporterInfo` (chip projection, public-safe fields only). +/// Returns `None` for `SupporterState::None` so callers can drop the +/// `supporter:` field entirely without a conditional. +fn supporter_to_public(status: SupporterStatus) -> Option { + match status.state { + SupporterState::Active | SupporterState::Lapsed => Some(PublicSupporterInfo { + state: status.state.as_str().to_string(), + current_tier_key: status.current_tier_key, + name_plate: status.name_plate, + }), + SupporterState::None => None, + } } /// Same as [`render_summary`] but applies a per-share scope clamp to @@ -1551,6 +1598,7 @@ async fn render_summary(query: &Q, handle: &str) -> Response { /// consistent (no `sum(by_type) != total` mismatch on the client). async fn render_summary_scoped( query: &Q, + supporters: &dyn SupporterStore, handle: &str, scope: Option<&ShareScope>, ) -> Response { @@ -1565,6 +1613,18 @@ async fn render_summary_scoped( } else { (total, by_type) }; + // Supporter chip — fail-soft on lookup error so a + // supporter-store hiccup doesn't 5xx the whole summary + // (the chip is decorative; the by_type render is the + // load-bearing payload). Log the warn line so prod + // observability flags the degradation. + let supporter = match supporters.get_by_handle_public(handle).await { + Ok(opt) => opt.and_then(supporter_to_public), + Err(e) => { + tracing::warn!(error = %e, handle, "supporter chip lookup failed"); + None + } + }; ( StatusCode::OK, Json(PublicSummaryResponse { @@ -1574,6 +1634,7 @@ async fn render_summary_scoped( .into_iter() .map(|(event_type, count)| PublicTypeCount { event_type, count }) .collect(), + supporter, }), ) .into_response() @@ -1647,6 +1708,7 @@ async fn render_timeline_scoped( pub async fn public_summary( State(query): State>, Extension(spicedb): Extension>>, + Extension(supporters): Extension>, Path(handle): Path, ) -> Response { if !validate_handle(&handle) { @@ -1665,7 +1727,7 @@ pub async fn public_summary( let check = check_public(client, &handle).await; render_or_404(check, || async { - render_summary(query.as_ref(), &handle).await + render_summary(query.as_ref(), supporters.as_ref(), &handle).await }) .await } @@ -1731,6 +1793,7 @@ pub async fn friend_summary( Extension(spicedb): Extension>>, Extension(meta): Extension>, Extension(audit): Extension>, + Extension(supporters): Extension>, auth: AuthenticatedUser, Path(handle): Path, ) -> Response { @@ -1773,7 +1836,7 @@ pub async fn friend_summary( } render_or_404(check, || async { emit_share_viewed(audit.as_ref(), &auth, &handle).await; - render_summary_scoped(query.as_ref(), &handle, scope.as_ref()).await + render_summary_scoped(query.as_ref(), supporters.as_ref(), &handle, scope.as_ref()).await }) .await } @@ -2410,6 +2473,7 @@ fn parse_scope_param(raw: Option<&str>) -> Result, Response> )] pub async fn preview_summary( State(query): State>, + Extension(supporters): Extension>, auth: AuthenticatedUser, Query(params): Query, ) -> Response { @@ -2427,12 +2491,19 @@ pub async fn preview_summary( claimed_handle: auth.preferred_username.clone(), total: 0, by_type: vec![], + supporter: None, }), ) .into_response(); } } - render_summary_scoped(query.as_ref(), &auth.preferred_username, scope.as_ref()).await + render_summary_scoped( + query.as_ref(), + supporters.as_ref(), + &auth.preferred_username, + scope.as_ref(), + ) + .await } #[utoipa::path( diff --git a/crates/starstats-server/src/supporters.rs b/crates/starstats-server/src/supporters.rs index ebe3804..c2802b1 100644 --- a/crates/starstats-server/src/supporters.rs +++ b/crates/starstats-server/src/supporters.rs @@ -104,6 +104,30 @@ pub trait SupporterStore: Send + Sync + 'static { /// model is "every user has a status, the default is none". async fn get(&self, user_id: Uuid) -> Result; + /// Public-safe lookup keyed by RSI handle, for rendering the + /// supporter chip on public + friend profile views. + /// + /// Returns `Ok(None)` when ANY of the following hold: + /// - no `users` row matches the handle (case-insensitive) + /// - the user has no `supporter_status` row + /// - the row's state is `none` + /// + /// Returns `Ok(Some(_))` only for `active` or `lapsed` states — + /// callers don't need to filter again. Wraps `SupporterStatus` + /// so it carries `current_tier_key` (tier styling) + + /// `name_plate` (display string) without surfacing fields like + /// `grace_until` / `last_payment_at` to public callers (the + /// route layer's `PublicSupporterInfo` projection drops them). + /// + /// The handle-keyed query JOINs `users` + `supporter_status` + + /// the most-recent completed `revolut_orders` row in one + /// round-trip — same lateral-join shape as `get`, just with a + /// handle lookup tacked on. + async fn get_by_handle_public( + &self, + handle: &str, + ) -> 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 @@ -188,6 +212,73 @@ impl SupporterStore for PostgresSupporterStore { }) } + async fn get_by_handle_public( + &self, + handle: &str, + ) -> Result, SupporterError> { + // Public-safe handle-keyed lookup. Inner-joins users + + // supporter_status so we get a row iff BOTH exist; the + // `state IN ('active','lapsed')` predicate excludes `none` + // (no chip should render for those users). Lateral join for + // current_tier_key mirrors `get`. + let row: Option<( + Uuid, + String, + Option, + Option>, + Option>, + Option>, + Option>, + DateTime, + Option, + )> = sqlx::query_as( + "SELECT 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) = LOWER($1) + AND s.state IN ('active', 'lapsed')", + ) + .bind(handle) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map( + |( + user_id, + state, + name_plate, + became, + last_pay, + grace, + cancelled, + updated, + tier_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, + } + }, + )) + } + async fn mark_payment_received( &self, user_id: Uuid, @@ -233,6 +324,11 @@ pub mod test_support { #[derive(Default)] pub struct MemorySupporterStore { rows: Mutex>, + // Handle -> user_id mapping for the public-handle lookup + // path. Tests that exercise `get_by_handle_public` seed both + // a row (via `seed`) AND a handle binding (via `bind_handle`); + // tests that only exercise `get` just call `seed`. + handles: Mutex>, } impl MemorySupporterStore { @@ -242,6 +338,16 @@ pub mod test_support { .expect("supporter memstore poisoned") .insert(status.user_id, status); } + + /// Bind a handle (case-insensitive) to a user_id so + /// `get_by_handle_public` resolves. Mirrors what users + + /// supporter_status joined together would surface. + pub fn bind_handle(&self, handle: &str, user_id: Uuid) { + self.handles + .lock() + .expect("supporter memstore poisoned") + .insert(handle.to_lowercase(), user_id); + } } #[async_trait] @@ -254,6 +360,25 @@ pub mod test_support { .unwrap_or_else(|| SupporterStatus::empty(user_id))) } + async fn get_by_handle_public( + &self, + handle: &str, + ) -> Result, SupporterError> { + let user_id = { + let handles = self.handles.lock().expect("supporter memstore poisoned"); + match handles.get(&handle.to_lowercase()) { + Some(uid) => *uid, + None => return Ok(None), + } + }; + let rows = self.rows.lock().expect("supporter memstore poisoned"); + let status = rows.get(&user_id).cloned(); + // Mirror the Postgres state filter: only `active` / + // `lapsed` resolve. + Ok(status + .filter(|s| matches!(s.state, SupporterState::Active | SupporterState::Lapsed))) + } + async fn mark_payment_received( &self, user_id: Uuid, @@ -358,4 +483,84 @@ mod tests { assert_eq!(s.name_plate.as_deref(), Some("Caelum")); assert_eq!(s.current_tier_key.as_deref(), Some("coffee")); } + + #[tokio::test] + async fn get_by_handle_public_returns_none_for_unknown_handle() { + let store = MemorySupporterStore::default(); + let result = store.get_by_handle_public("Nobody").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn get_by_handle_public_returns_none_for_unbound_handle() { + // Row exists but handle binding is missing — handle-keyed + // lookup must miss even though user_id-keyed lookup would + // find it. Mirrors the Postgres JOIN failing on + // `users.claimed_handle`. + let store = MemorySupporterStore::default(); + let user_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_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("coffee".into()), + }); + let result = store.get_by_handle_public("Caelum").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn get_by_handle_public_returns_active_row_when_bound() { + let store = MemorySupporterStore::default(); + let user_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_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("generous".into()), + }); + store.bind_handle("Caelum", user_id); + // Lookup is case-insensitive — mirrors LOWER() in SQL. + let result = store + .get_by_handle_public("caelum") + .await + .unwrap() + .expect("supporter present"); + assert_eq!(result.state, SupporterState::Active); + assert_eq!(result.current_tier_key.as_deref(), Some("generous")); + assert_eq!(result.name_plate.as_deref(), Some("Caelum")); + } + + #[tokio::test] + async fn get_by_handle_public_filters_none_state() { + // `state = none` users should NOT surface in the public + // lookup even when bound — the chip render path expects + // either active or lapsed. + let store = MemorySupporterStore::default(); + let user_id = Uuid::now_v7(); + store.seed(SupporterStatus { + user_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", user_id); + let result = store.get_by_handle_public("Quiet").await.unwrap(); + assert!(result.is_none(), "state=none must not surface publicly"); + } } diff --git a/packages/api-client-ts/src/generated/schema.ts b/packages/api-client-ts/src/generated/schema.ts index 6490de1..0460411 100644 --- a/packages/api-client-ts/src/generated/schema.ts +++ b/packages/api-client-ts/src/generated/schema.ts @@ -3662,9 +3662,33 @@ export interface components { PublicSummaryResponse: { by_type: components["schemas"]["PublicTypeCount"][]; claimed_handle: string; + supporter?: null | components["schemas"]["PublicSupporterInfo"]; /** Format: int64 */ total: number; }; + /** + * @description Public-safe supporter projection for the profile chip. Mirrors + * the minimum fields the web `` needs and nothing + * else. + */ + PublicSupporterInfo: { + /** + * @description `coffee` / `standard` / `generous`. `None` when no completed + * order exists yet (should not happen for live data but the + * frontend renders a tier-less fallback rather than crashing). + */ + current_tier_key?: string | null; + /** + * @description Optional 28-char display string; `None` when the user has not + * set one. + */ + name_plate?: string | null; + /** + * @description Either `active` or `lapsed`. `none` is never serialised here; + * the outer `supporter` field is `None` instead. + */ + state: string; + }; PublicTimelineBucket: { /** Format: int64 */ count: number;