Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions apps/web/src/app/u/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -331,7 +344,7 @@ export default async function PublicProfilePage(props: PageProps) {
{profile && (
<span className="ss-badge ss-badge--ok">RSI verified</span>
)}
<SupporterChip status={supporter} />
<SupporterChip status={chipStatus} />
</div>
</div>
{/* Sharing CTAs — context-sensitive deep links into /sharing.
Expand Down
5 changes: 5 additions & 0 deletions crates/starstats-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ async fn main() -> anyhow::Result<()> {
let share_metadata_dyn: Arc<dyn crate::share_metadata::ShareMetadataStore> =
share_metadata.clone();
let share_reports_dyn: Arc<dyn crate::share_reports::ShareReportStore> = 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<dyn crate::supporters::SupporterStore> = supporter_store.clone();

// Roadmap pipeline (Phases 1-8). The store is always constructed
// so the public read API works even without GitHub credentials —
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions crates/starstats-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 76 additions & 5 deletions crates/starstats-server/src/sharing_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -326,6 +327,33 @@ pub struct PublicSummaryResponse {
pub claimed_handle: String,
pub total: u64,
pub by_type: Vec<PublicTypeCount>,
/// Supporter chip data for public + friend views. `None` when
/// the user has never donated (state = 'none' or no row) — the
/// web `<SupporterChip>` 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<PublicSupporterInfo>,
}

/// Public-safe supporter projection for the profile chip. Mirrors
/// the minimum fields the web `<SupporterChip>` 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<String>,
/// Optional 28-char display string; `None` when the user has not
/// set one.
pub name_plate: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
Expand Down Expand Up @@ -1541,8 +1569,27 @@ where
}
}

async fn render_summary<Q: EventQuery>(query: &Q, handle: &str) -> Response {
render_summary_scoped(query, handle, None).await
async fn render_summary<Q: EventQuery>(
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<PublicSupporterInfo> {
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
Expand All @@ -1551,6 +1598,7 @@ async fn render_summary<Q: EventQuery>(query: &Q, handle: &str) -> Response {
/// consistent (no `sum(by_type) != total` mismatch on the client).
async fn render_summary_scoped<Q: EventQuery>(
query: &Q,
supporters: &dyn SupporterStore,
handle: &str,
scope: Option<&ShareScope>,
) -> Response {
Expand All @@ -1565,6 +1613,18 @@ async fn render_summary_scoped<Q: EventQuery>(
} 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 {
Expand All @@ -1574,6 +1634,7 @@ async fn render_summary_scoped<Q: EventQuery>(
.into_iter()
.map(|(event_type, count)| PublicTypeCount { event_type, count })
.collect(),
supporter,
}),
)
.into_response()
Expand Down Expand Up @@ -1647,6 +1708,7 @@ async fn render_timeline_scoped<Q: EventQuery>(
pub async fn public_summary<Q: EventQuery>(
State(query): State<Arc<Q>>,
Extension(spicedb): Extension<Arc<Option<SpicedbClient>>>,
Extension(supporters): Extension<Arc<dyn SupporterStore>>,
Path(handle): Path<String>,
) -> Response {
if !validate_handle(&handle) {
Expand All @@ -1665,7 +1727,7 @@ pub async fn public_summary<Q: EventQuery>(

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
}
Expand Down Expand Up @@ -1731,6 +1793,7 @@ pub async fn friend_summary<Q: EventQuery>(
Extension(spicedb): Extension<Arc<Option<SpicedbClient>>>,
Extension(meta): Extension<Arc<dyn ShareMetadataStore>>,
Extension(audit): Extension<Arc<dyn AuditLog>>,
Extension(supporters): Extension<Arc<dyn SupporterStore>>,
auth: AuthenticatedUser,
Path(handle): Path<String>,
) -> Response {
Expand Down Expand Up @@ -1773,7 +1836,7 @@ pub async fn friend_summary<Q: EventQuery>(
}
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
}
Expand Down Expand Up @@ -2410,6 +2473,7 @@ fn parse_scope_param(raw: Option<&str>) -> Result<Option<ShareScope>, Response>
)]
pub async fn preview_summary<Q: EventQuery>(
State(query): State<Arc<Q>>,
Extension(supporters): Extension<Arc<dyn SupporterStore>>,
auth: AuthenticatedUser,
Query(params): Query<PreviewSummaryParams>,
) -> Response {
Expand All @@ -2427,12 +2491,19 @@ pub async fn preview_summary<Q: EventQuery>(
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(
Expand Down
Loading
Loading