feat(leaderboard): opt-out support — server endpoint, client method, UI button
- Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0, hiding the player without deleting their row (scores preserved for re-opt-in) - SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl - SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh - Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button - Server integration test: opt-out hides, opt-in restores (round-trip verified) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,11 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
|
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Remove the authenticated player from the leaderboard.
|
||||||
|
/// No-op for backends that don't support leaderboards.
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||||
@@ -76,6 +81,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||||
(**self).opt_in_leaderboard(display_name).await
|
(**self).opt_in_leaderboard(display_name).await
|
||||||
}
|
}
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
(**self).opt_out_leaderboard().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|||||||
@@ -261,6 +261,40 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
let token = self.access_token()?;
|
let token = self.access_token()?;
|
||||||
let url = format!("{}/api/leaderboard", self.base_url);
|
let url = format!("{}/api/leaderboard", self.base_url);
|
||||||
|
|||||||
@@ -45,10 +45,18 @@ pub struct LeaderboardScreen;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct LeaderboardOptInButton;
|
struct LeaderboardOptInButton;
|
||||||
|
|
||||||
|
/// Marker on the "Opt Out" button inside the leaderboard panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardOptOutButton;
|
||||||
|
|
||||||
/// In-flight opt-in task.
|
/// In-flight opt-in task.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct OptInTask(Option<Task<Result<(), String>>>);
|
struct OptInTask(Option<Task<Result<(), String>>>);
|
||||||
|
|
||||||
|
/// In-flight opt-out task.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,6 +70,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<LeaderboardFetchTask>()
|
.init_resource::<LeaderboardFetchTask>()
|
||||||
.init_resource::<ClosedThisFrame>()
|
.init_resource::<ClosedThisFrame>()
|
||||||
.init_resource::<OptInTask>()
|
.init_resource::<OptInTask>()
|
||||||
|
.init_resource::<OptOutTask>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -71,6 +80,8 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
update_leaderboard_panel,
|
update_leaderboard_panel,
|
||||||
handle_opt_in_button,
|
handle_opt_in_button,
|
||||||
poll_opt_in_task,
|
poll_opt_in_task,
|
||||||
|
handle_opt_out_button,
|
||||||
|
poll_opt_out_task,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
@@ -213,6 +224,37 @@ fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fires an async opt-out request when the player presses the "Opt Out" button.
|
||||||
|
fn handle_opt_out_button(
|
||||||
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaderboardOptOutButton>)>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptOutTask>,
|
||||||
|
) {
|
||||||
|
if task_res.0.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(provider) = provider else { return };
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) });
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the opt-out task; logs on error, clears on completion.
|
||||||
|
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
|
task_res.0 = None;
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("leaderboard opt-out failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -257,7 +299,7 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
|
|||||||
TextColor(Color::WHITE),
|
TextColor(Color::WHITE),
|
||||||
));
|
));
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new("Press L to close • Opt in to appear on the board"),
|
Text::new("Press L to close • Opt In / Opt Out to control your visibility"),
|
||||||
TextFont { font_size: 14.0, ..default() },
|
TextFont { font_size: 14.0, ..default() },
|
||||||
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||||
));
|
));
|
||||||
@@ -272,15 +314,20 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
|
|||||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Opt-in button
|
// Opt-in / Opt-out buttons row
|
||||||
card.spawn((
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(10.0),
|
||||||
|
margin: UiRect::bottom(Val::Px(8.0)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
LeaderboardOptInButton,
|
LeaderboardOptInButton,
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
margin: UiRect::bottom(Val::Px(8.0)),
|
|
||||||
align_self: AlignSelf::FlexStart,
|
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
||||||
@@ -288,12 +335,32 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
|
|||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new("Opt In to Leaderboard"),
|
Text::new("Opt In"),
|
||||||
TextFont { font_size: 15.0, ..default() },
|
TextFont { font_size: 15.0, ..default() },
|
||||||
TextColor(Color::WHITE),
|
TextColor(Color::WHITE),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
row.spawn((
|
||||||
|
LeaderboardOptOutButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Opt Out"),
|
||||||
|
TextFont { font_size: 15.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
match entries {
|
match entries {
|
||||||
None => {
|
None => {
|
||||||
// Fetch in progress
|
// Fetch in progress
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ pub async fn get_leaderboard(
|
|||||||
Ok(Json(entries?))
|
Ok(Json(entries?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/leaderboard/opt-in` — opt out, hiding the player's entry.
|
||||||
|
///
|
||||||
|
/// Sets `leaderboard_opt_in = 0` on the user row so the entry no longer
|
||||||
|
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
|
||||||
|
/// so scores are preserved if the player opts back in later.
|
||||||
|
pub async fn opt_out(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
|
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
|
||||||
///
|
///
|
||||||
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
|||||||
.route("/api/sync/push", post(sync::push))
|
.route("/api/sync/push", post(sync::push))
|
||||||
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||||
|
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||||
.route("/api/account", delete(auth::delete_account))
|
.route("/api/account", delete(auth::delete_account))
|
||||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||||
|
|
||||||
|
|||||||
@@ -776,3 +776,58 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
|||||||
assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
|
assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
|
||||||
assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest");
|
assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_out_hides_then_opt_in_restores() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
|
let (access, _) = register_user(app.clone(), "visible", "pass1234").await;
|
||||||
|
|
||||||
|
// Opt in.
|
||||||
|
let resp = post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": "Visible" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Verify they appear.
|
||||||
|
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||||
|
let entries = body_json(lb).await;
|
||||||
|
assert!(
|
||||||
|
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||||
|
"opted-in user must appear"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opt out.
|
||||||
|
let resp = delete_authed(app.clone(), "/api/leaderboard/opt-in", &access).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Verify they are hidden.
|
||||||
|
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||||
|
let entries = body_json(lb).await;
|
||||||
|
assert!(
|
||||||
|
!entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||||
|
"opted-out user must be hidden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opt back in — should restore without losing display name.
|
||||||
|
post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": "Visible" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||||
|
let entries = body_json(lb).await;
|
||||||
|
assert!(
|
||||||
|
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||||
|
"re-opted-in user must appear again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user