From 2a78c4a092f80cb1c8cdf5642b1566029e4071ac Mon Sep 17 00:00:00 2001 From: Ahmad Baalbaky Date: Thu, 15 Jan 2026 15:38:09 +0100 Subject: [PATCH 1/2] feat: GQL node `QueryRoot.trendingEventEditions` This new GraphQL node returns the trending event editions, meaning the editions where players set the most records in the last days, with a certain limit. Refs: #102. --- crates/game_api/src/env.rs | 15 ++++-- crates/game_api/src/main.rs | 25 +++++----- crates/graphql-api/src/objects/mappack.rs | 5 +- crates/graphql-api/src/objects/root.rs | 59 +++++++++++++++++++++-- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/crates/game_api/src/env.rs b/crates/game_api/src/env.rs index 9e1b987..76217ec 100644 --- a/crates/game_api/src/env.rs +++ b/crates/game_api/src/env.rs @@ -1,13 +1,23 @@ +use std::error::Error; + +use actix_web::cookie::Key; use mkenv::{make_config, prelude::*}; use once_cell::sync::OnceCell; use records_lib::{DbEnv, LibEnv}; +fn parse_session_key(input: &str) -> Result> { + Key::try_from(input.as_bytes()).map_err(From::from) +} + #[cfg(not(debug_assertions))] mkenv::make_config! { pub struct DynamicApiEnv { pub sess_key: { var_name: "RECORDS_API_SESSION_KEY_FILE", - layers: [file_read()], + layers: [ + file_read(), + parsed(parse_session_key), + ], description: "The path to the file containing the session key used by the API", }, @@ -30,9 +40,8 @@ mkenv::make_config! { pub struct DynamicApiEnv { pub sess_key: { var_name: "RECORDS_API_SESSION_KEY", - layers: [or_default()], + layers: [parsed(parse_session_key)], description: "The session key used by the API", - default_val_fmt: "empty", }, pub mp_client_id: { diff --git a/crates/game_api/src/main.rs b/crates/game_api/src/main.rs index 7633e40..081c425 100644 --- a/crates/game_api/src/main.rs +++ b/crates/game_api/src/main.rs @@ -9,11 +9,7 @@ use actix_session::{ config::{CookieContentSecurity, PersistentSession}, storage::CookieSessionStore, }; -use actix_web::{ - App, HttpServer, - cookie::{Key, time::Duration as CookieDuration}, - middleware, -}; +use actix_web::{App, HttpServer, cookie::time::Duration as CookieDuration, middleware}; use anyhow::Context; use game_api_lib::configure; use migration::MigratorTrait; @@ -76,8 +72,6 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Using max connections: {max_connections}"); - let sess_key = Key::from(game_api_lib::env().dynamic.sess_key.get().as_bytes()); - HttpServer::new(move || { let cors = Cors::default() .supports_credentials() @@ -95,13 +89,16 @@ async fn main() -> anyhow::Result<()> { .wrap(middleware::from_fn(configure::fit_request_id)) .wrap(TracingLogger::::new()) .wrap( - SessionMiddleware::builder(CookieSessionStore::default(), sess_key.clone()) - .cookie_secure(cfg!(not(debug_assertions))) - .cookie_content_security(CookieContentSecurity::Private) - .session_lifecycle(PersistentSession::default().session_ttl( - CookieDuration::seconds(game_api_lib::env().auth_token_ttl.get() as i64), - )) - .build(), + SessionMiddleware::builder( + CookieSessionStore::default(), + game_api_lib::env().dynamic.sess_key.get(), + ) + .cookie_secure(cfg!(not(debug_assertions))) + .cookie_content_security(CookieContentSecurity::Private) + .session_lifecycle(PersistentSession::default().session_ttl( + CookieDuration::seconds(game_api_lib::env().auth_token_ttl.get() as i64), + )) + .build(), ) .configure(|cfg| configure::configure(cfg, db.clone())) }) diff --git a/crates/graphql-api/src/objects/mappack.rs b/crates/graphql-api/src/objects/mappack.rs index 7146720..bca7210 100644 --- a/crates/graphql-api/src/objects/mappack.rs +++ b/crates/graphql-api/src/objects/mappack.rs @@ -146,12 +146,15 @@ impl Mappack { async fn leaderboard<'a>( &'a self, ctx: &async_graphql::Context<'_>, + limit: Option, ) -> GqlResult>> { let db = ctx.data_unchecked::(); let mut redis_conn = db.redis_pool.get().await?; + let limit = limit.map(|l| l.saturating_sub(1)).unwrap_or(-1); + let leaderboard: Vec = redis_conn - .zrange(mappack_lb_key(AnyMappackId::Id(&self.mappack_id)), 0, -1) + .zrange(mappack_lb_key(AnyMappackId::Id(&self.mappack_id)), 0, limit) .await?; let mut out = Vec::with_capacity(leaderboard.len()); diff --git a/crates/graphql-api/src/objects/root.rs b/crates/graphql-api/src/objects/root.rs index 7d7c1a2..f669237 100644 --- a/crates/graphql-api/src/objects/root.rs +++ b/crates/graphql-api/src/objects/root.rs @@ -1,13 +1,16 @@ +use std::borrow::Cow; + use async_graphql::{ ID, connection::{self, CursorType}, }; use deadpool_redis::redis::{AsyncCommands, ToRedisArgs}; use entity::{ - event as event_entity, event_edition, functions, global_records, maps, players, records, + event as event_entity, event_edition, event_edition_records, functions, global_records, maps, + players, records, }; use records_lib::{ - Database, RedisConnection, RedisPool, must, + Database, RedisConnection, RedisPool, internal, must, opt_event::OptEvent, ranks, redis_key::{MapRanking, PlayerRanking, map_ranking, player_ranking}, @@ -15,7 +18,8 @@ use records_lib::{ }; use sea_orm::{ ColumnTrait, ConnectionTrait, DbConn, EntityTrait, FromQueryResult, Identity, QueryFilter as _, - QueryOrder as _, QuerySelect, Select, SelectModel, StreamTrait, TransactionTrait, + QueryOrder as _, QuerySelect, RelationTrait as _, Select, SelectModel, StreamTrait, + TransactionTrait, prelude::Expr, sea_query::{Asterisk, ExprTrait as _, Func, IntoIden, IntoValueTuple, SelectStatement}, }; @@ -257,6 +261,55 @@ impl QueryRoot { Ok(r) } + async fn trending_event_editions( + &self, + ctx: &async_graphql::Context<'_>, + last_days: Option, + limit: Option, + ) -> GqlResult>> { + let conn = ctx.data_unchecked::(); + + let limit = limit.unwrap_or(3).min(5); + let last_days = last_days.unwrap_or(7).min(30); + + let editions = event_edition::Entity::find() + .reverse_join(event_edition_records::Entity) + .join( + sea_orm::JoinType::InnerJoin, + event_edition_records::Relation::Records.def(), + ) + .filter( + Expr::current_timestamp().lt(Func::cust("TIMESTAMPADD") + .arg(Expr::custom_keyword("DAY")) + .arg(last_days) + .arg(Expr::col((records::Entity, records::Column::RecordDate)))), + ) + .find_also_related(event_entity::Entity) + .expr_as(Expr::col(Asterisk).count(), "records_count") + .group_by(event_edition::Column::EventId) + .group_by(event_edition::Column::Id) + .order_by_desc(Expr::col("records_count")) + .limit(limit) + .all(conn) + .await? + .into_iter() + .map(|(edition, event)| { + GqlResult::Ok(EventEdition { + event: Cow::Owned( + event + .ok_or_else(|| { + internal!("event {} should be present", edition.event_id) + })? + .into(), + ), + inner: edition, + }) + }) + .collect::, _>>()?; + + Ok(editions) + } + async fn record( &self, ctx: &async_graphql::Context<'_>, From f12ebc2d2913c0080992c9a9e7658ae52875d90f Mon Sep 17 00:00:00 2001 From: Ahmad Baalbaky Date: Fri, 16 Jan 2026 17:25:05 +0100 Subject: [PATCH 2/2] fix: update CI accordingly Refs: #102. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31bddad..33a04cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: DATABASE_URL: mysql://api:${{ secrets.MARIADB_PW }}@localhost:3306/master_db REDIS_URL: redis://localhost:6379 GQL_API_CURSOR_SECRET_KEY: ${{ secrets.GQL_API_CURSOR_SECRET_KEY }} + RECORDS_API_SESSION_KEY: ${{ secrets.RECORDS_API_SESSION_KEY }} run: RUST_BACKTRACE=1 cargo test -F mysql lint: