diff --git a/.gitattributes b/.gitattributes index fb6fa53e..4ae6a59f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -Cargo.nix linguist-generated +Cargo.nix linguist-generated text eol=lf diff --git a/.gitignore b/.gitignore index 6c65d4b1..3b650299 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ repl-result-* .invoices .credit_notes .nixos-test-history +/.tmp/ +.idea/ +dump.rdb diff --git a/Agents.md b/Agents.md new file mode 100644 index 00000000..573d4282 --- /dev/null +++ b/Agents.md @@ -0,0 +1,2 @@ +Your instructions are located at ../Agents.md. +Read the ENTIRE file. They are INCREDIBLY important. diff --git a/Cargo.lock b/Cargo.lock index e3167c09..cbfb638a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "academy_core_coin_impl", "academy_core_config_impl", "academy_core_contact_impl", + "academy_core_daily_rewards_impl", "academy_core_finance_contracts", "academy_core_finance_impl", "academy_core_health_impl", @@ -68,6 +69,7 @@ dependencies = [ "academy_core_coin_contracts", "academy_core_config_contracts", "academy_core_contact_contracts", + "academy_core_daily_rewards_contracts", "academy_core_finance_contracts", "academy_core_health_contracts", "academy_core_heart_contracts", @@ -86,6 +88,7 @@ dependencies = [ "axum", "axum-extra", "base64 0.22.1", + "chrono", "futures", "mime", "regex", @@ -244,6 +247,47 @@ dependencies = [ "tracing", ] +[[package]] +name = "academy_core_daily_rewards_contracts" +version = "0.0.0" +dependencies = [ + "academy_models", + "anyhow", + "chrono", + "mockall", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "academy_core_daily_rewards_impl" +version = "0.0.0" +dependencies = [ + "academy_auth_contracts", + "academy_cache_contracts", + "academy_core_coin_contracts", + "academy_core_daily_rewards_contracts", + "academy_di", + "academy_models", + "academy_persistence_contracts", + "academy_shared_contracts", + "academy_utils", + "anyhow", + "bb8", + "bb8-postgres", + "chrono", + "mockall", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-postgres", + "tracing", + "uuid", +] + [[package]] name = "academy_core_finance_contracts" version = "0.0.0" @@ -650,6 +694,7 @@ dependencies = [ "hex", "lettre", "nutype", + "postgres-types", "regex", "schemars", "serde", @@ -668,7 +713,9 @@ dependencies = [ "chrono", "futures", "mockall", + "serde_json", "thiserror 2.0.17", + "uuid", ] [[package]] @@ -688,8 +735,11 @@ dependencies = [ "clorinde", "futures", "ouroboros", + "postgres-types", "pretty_assertions", + "serde_json", "tokio", + "tokio-postgres", "tracing", "uuid", ] @@ -2927,6 +2977,8 @@ dependencies = [ "fallible-iterator", "postgres-derive", "postgres-protocol", + "serde_core", + "serde_json", "uuid", ] @@ -3527,6 +3579,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", diff --git a/Cargo.nix b/Cargo.nix index 6030d295..2d6c18a0 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -177,6 +177,26 @@ rec { # File a bug if you depend on any for non-debug work! debug = internal.debugCrate { inherit packageId; }; }; + "academy_core_daily_rewards_contracts" = rec { + packageId = "academy_core_daily_rewards_contracts"; + build = internal.buildRustCrateWithFeatures { + packageId = "academy_core_daily_rewards_contracts"; + }; + + # Debug support which might change between releases. + # File a bug if you depend on any for non-debug work! + debug = internal.debugCrate { inherit packageId; }; + }; + "academy_core_daily_rewards_impl" = rec { + packageId = "academy_core_daily_rewards_impl"; + build = internal.buildRustCrateWithFeatures { + packageId = "academy_core_daily_rewards_impl"; + }; + + # Debug support which might change between releases. + # File a bug if you depend on any for non-debug work! + debug = internal.debugCrate { inherit packageId; }; + }; "academy_core_finance_contracts" = rec { packageId = "academy_core_finance_contracts"; build = internal.buildRustCrateWithFeatures { @@ -642,6 +662,10 @@ rec { name = "academy_core_contact_impl"; packageId = "academy_core_contact_impl"; } + { + name = "academy_core_daily_rewards_impl"; + packageId = "academy_core_daily_rewards_impl"; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; @@ -883,6 +907,10 @@ rec { name = "academy_core_contact_contracts"; packageId = "academy_core_contact_contracts"; } + { + name = "academy_core_daily_rewards_contracts"; + packageId = "academy_core_daily_rewards_contracts"; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; @@ -964,6 +992,12 @@ rec { packageId = "base64 0.22.1"; usesDefaultFeatures = false; } + { + name = "chrono"; + packageId = "chrono"; + usesDefaultFeatures = false; + features = [ "serde" "clock" ]; + } { name = "futures"; packageId = "futures"; @@ -984,7 +1018,7 @@ rec { name = "schemars"; packageId = "schemars"; usesDefaultFeatures = false; - features = [ "derive" "preserve_order" "uuid1" "url2" ]; + features = [ "chrono04" "derive" "preserve_order" "uuid1" "url2" ]; } { name = "serde"; @@ -1597,6 +1631,219 @@ rec { } ]; + }; + "academy_core_daily_rewards_contracts" = rec { + crateName = "academy_core_daily_rewards_contracts"; + version = "0.0.0"; + edition = "2024"; + src = lib.cleanSourceWith { filter = sourceFilter; src = ./academy_core/daily_rewards/contracts; }; + dependencies = [ + { + name = "academy_models"; + packageId = "academy_models"; + } + { + name = "anyhow"; + packageId = "anyhow"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "chrono"; + packageId = "chrono"; + usesDefaultFeatures = false; + features = [ "serde" "clock" ]; + } + { + name = "mockall"; + packageId = "mockall"; + optional = true; + usesDefaultFeatures = false; + } + { + name = "schemars"; + packageId = "schemars"; + usesDefaultFeatures = false; + features = [ "chrono04" "derive" "preserve_order" "uuid1" "url2" ]; + } + { + name = "serde"; + packageId = "serde"; + usesDefaultFeatures = false; + features = [ "derive" "std" ]; + } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "thiserror"; + packageId = "thiserror 2.0.17"; + usesDefaultFeatures = false; + } + ]; + features = { + "mock" = [ "dep:mockall" ]; + }; + resolvedDefaultFeatures = [ "mock" ]; + }; + "academy_core_daily_rewards_impl" = rec { + crateName = "academy_core_daily_rewards_impl"; + version = "0.0.0"; + edition = "2024"; + src = lib.cleanSourceWith { filter = sourceFilter; src = ./academy_core/daily_rewards/impl; }; + dependencies = [ + { + name = "academy_auth_contracts"; + packageId = "academy_auth_contracts"; + } + { + name = "academy_cache_contracts"; + packageId = "academy_cache_contracts"; + } + { + name = "academy_core_coin_contracts"; + packageId = "academy_core_coin_contracts"; + } + { + name = "academy_core_daily_rewards_contracts"; + packageId = "academy_core_daily_rewards_contracts"; + } + { + name = "academy_di"; + packageId = "academy_di"; + } + { + name = "academy_models"; + packageId = "academy_models"; + } + { + name = "academy_persistence_contracts"; + packageId = "academy_persistence_contracts"; + } + { + name = "academy_shared_contracts"; + packageId = "academy_shared_contracts"; + } + { + name = "academy_utils"; + packageId = "academy_utils"; + } + { + name = "anyhow"; + packageId = "anyhow"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "bb8"; + packageId = "bb8"; + usesDefaultFeatures = false; + } + { + name = "bb8-postgres"; + packageId = "bb8-postgres"; + usesDefaultFeatures = false; + features = [ "with-chrono-0_4" "with-uuid-1" "with-serde_json-1" ]; + } + { + name = "chrono"; + packageId = "chrono"; + usesDefaultFeatures = false; + features = [ "serde" "clock" ]; + } + { + name = "reqwest"; + packageId = "reqwest"; + usesDefaultFeatures = false; + features = [ "http2" "rustls-tls" "json" ]; + } + { + name = "serde"; + packageId = "serde"; + usesDefaultFeatures = false; + features = [ "derive" "std" ]; + } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "tokio-postgres"; + packageId = "tokio-postgres"; + features = [ "with-chrono-0_4" "with-uuid-1" "with-serde_json-1" ]; + } + { + name = "tracing"; + packageId = "tracing"; + usesDefaultFeatures = false; + features = [ "attributes" ]; + } + { + name = "uuid"; + packageId = "uuid"; + usesDefaultFeatures = false; + features = [ "v4" "v7" "serde" ]; + } + ]; + devDependencies = [ + { + name = "academy_auth_contracts"; + packageId = "academy_auth_contracts"; + features = [ "mock" ]; + } + { + name = "academy_cache_contracts"; + packageId = "academy_cache_contracts"; + features = [ "mock" ]; + } + { + name = "academy_core_coin_contracts"; + packageId = "academy_core_coin_contracts"; + features = [ "mock" ]; + } + { + name = "academy_core_daily_rewards_contracts"; + packageId = "academy_core_daily_rewards_contracts"; + features = [ "mock" ]; + } + { + name = "academy_models"; + packageId = "academy_models"; + } + { + name = "academy_persistence_contracts"; + packageId = "academy_persistence_contracts"; + features = [ "mock" ]; + } + { + name = "academy_shared_contracts"; + packageId = "academy_shared_contracts"; + features = [ "mock" ]; + } + { + name = "mockall"; + packageId = "mockall"; + usesDefaultFeatures = false; + } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "tokio"; + packageId = "tokio"; + usesDefaultFeatures = false; + features = [ "rt-multi-thread" "macros" "sync" "fs" "process" ]; + } + ]; + }; "academy_core_finance_contracts" = rec { crateName = "academy_core_finance_contracts"; @@ -3361,6 +3608,12 @@ rec { usesDefaultFeatures = false; features = [ "std" "regex" "serde" "schemars08" ]; } + { + name = "postgres-types"; + packageId = "postgres-types"; + usesDefaultFeatures = false; + features = [ "derive" ]; + } { name = "regex"; packageId = "regex"; @@ -3370,7 +3623,7 @@ rec { name = "schemars"; packageId = "schemars"; usesDefaultFeatures = false; - features = [ "derive" "preserve_order" "uuid1" "url2" ]; + features = [ "chrono04" "derive" "preserve_order" "uuid1" "url2" ]; } { name = "serde"; @@ -3378,6 +3631,12 @@ rec { usesDefaultFeatures = false; features = [ "derive" "std" ]; } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } { name = "thiserror"; packageId = "thiserror 2.0.17"; @@ -3396,14 +3655,6 @@ rec { features = [ "v4" "v7" "serde" ]; } ]; - devDependencies = [ - { - name = "serde_json"; - packageId = "serde_json"; - usesDefaultFeatures = false; - features = [ "std" ]; - } - ]; }; "academy_persistence_contracts" = rec { @@ -3440,11 +3691,23 @@ rec { optional = true; usesDefaultFeatures = false; } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } { name = "thiserror"; packageId = "thiserror 2.0.17"; usesDefaultFeatures = false; } + { + name = "uuid"; + packageId = "uuid"; + usesDefaultFeatures = false; + features = [ "v4" "v7" "serde" ]; + } ]; features = { "mock" = [ "dep:mockall" ]; @@ -3488,7 +3751,7 @@ rec { name = "bb8-postgres"; packageId = "bb8-postgres"; usesDefaultFeatures = false; - features = [ "with-chrono-0_4" "with-uuid-1" ]; + features = [ "with-chrono-0_4" "with-uuid-1" "with-serde_json-1" ]; } { name = "chrono"; @@ -3511,6 +3774,24 @@ rec { packageId = "ouroboros"; usesDefaultFeatures = false; } + { + name = "postgres-types"; + packageId = "postgres-types"; + usesDefaultFeatures = false; + features = [ "derive" "with-serde_json-1" "with-chrono-0_4" "with-uuid-1" ]; + } + { + name = "serde_json"; + packageId = "serde_json"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "tokio-postgres"; + packageId = "tokio-postgres"; + usesDefaultFeatures = false; + features = [ "with-chrono-0_4" "with-uuid-1" "with-serde_json-1" ]; + } { name = "tracing"; packageId = "tracing"; @@ -5148,7 +5429,7 @@ rec { "with-uuid-0_8" = [ "tokio-postgres/with-uuid-0_8" ]; "with-uuid-1" = [ "tokio-postgres/with-uuid-1" ]; }; - resolvedDefaultFeatures = [ "with-chrono-0_4" "with-uuid-1" ]; + resolvedDefaultFeatures = [ "with-chrono-0_4" "with-serde_json-1" "with-uuid-1" ]; }; "bb8-redis" = rec { crateName = "bb8-redis"; @@ -10533,6 +10814,18 @@ rec { name = "postgres-protocol"; packageId = "postgres-protocol"; } + { + name = "serde_core"; + packageId = "serde_core"; + rename = "serde-1"; + optional = true; + } + { + name = "serde_json"; + packageId = "serde_json"; + rename = "serde_json-1"; + optional = true; + } { name = "uuid"; packageId = "uuid"; @@ -10584,7 +10877,7 @@ rec { "with-uuid-0_8" = [ "uuid-08" ]; "with-uuid-1" = [ "uuid-1" ]; }; - resolvedDefaultFeatures = [ "chrono-04" "derive" "postgres-derive" "uuid-1" "with-chrono-0_4" "with-uuid-1" ]; + resolvedDefaultFeatures = [ "chrono-04" "derive" "postgres-derive" "serde-1" "serde_json-1" "uuid-1" "with-chrono-0_4" "with-serde_json-1" "with-uuid-1" ]; }; "potential_utf" = rec { crateName = "potential_utf"; @@ -12750,6 +13043,13 @@ rec { "Graham Esau " ]; dependencies = [ + { + name = "chrono"; + packageId = "chrono"; + rename = "chrono04"; + optional = true; + usesDefaultFeatures = false; + } { name = "dyn-clone"; packageId = "dyn-clone"; @@ -12798,6 +13098,13 @@ rec { } ]; devDependencies = [ + { + name = "chrono"; + packageId = "chrono"; + rename = "chrono04"; + usesDefaultFeatures = false; + features = [ "serde" ]; + } { name = "indexmap"; packageId = "indexmap"; @@ -12845,7 +13152,7 @@ rec { "url2" = [ "dep:url2" ]; "uuid1" = [ "dep:uuid1" ]; }; - resolvedDefaultFeatures = [ "default" "derive" "indexmap2" "preserve_order" "schemars_derive" "std" "url2" "uuid1" ]; + resolvedDefaultFeatures = [ "chrono04" "default" "derive" "indexmap2" "preserve_order" "schemars_derive" "std" "url2" "uuid1" ]; }; "schemars_derive" = rec { crateName = "schemars_derive"; @@ -14826,7 +15133,7 @@ rec { "with-uuid-0_8" = [ "postgres-types/with-uuid-0_8" ]; "with-uuid-1" = [ "postgres-types/with-uuid-1" ]; }; - resolvedDefaultFeatures = [ "default" "runtime" "with-chrono-0_4" "with-uuid-1" ]; + resolvedDefaultFeatures = [ "default" "runtime" "with-chrono-0_4" "with-serde_json-1" "with-uuid-1" ]; }; "tokio-rustls" = rec { crateName = "tokio-rustls"; diff --git a/Cargo.toml b/Cargo.toml index ca8c1a19..c7ca9161 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,8 @@ academy_core_coin_contracts.path = "academy_core/coin/contracts" academy_core_coin_impl.path = "academy_core/coin/impl" academy_core_config_contracts.path = "academy_core/config/contracts" academy_core_config_impl.path = "academy_core/config/impl" +academy_core_daily_rewards_contracts.path = "academy_core/daily_rewards/contracts" +academy_core_daily_rewards_impl.path = "academy_core/daily_rewards/impl" academy_core_contact_contracts.path = "academy_core/contact/contracts" academy_core_contact_impl.path = "academy_core/contact/impl" academy_core_finance_contracts.path = "academy_core/finance/contracts" @@ -131,6 +133,7 @@ mockall = { version = "0.13.1", default-features = false } nutype = { version = "0.6.2", default-features = false, features = ["std", "regex", "serde", "schemars08"] } oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest", "rustls-tls"] } pretty_assertions = { version = "1.4.1", default-features = false, features = ["std"] } +postgres-types = { version = "0.2.11", default-features = false, features = ["derive"] } proc-macro2 = { version = "1.0.103", default-features = false, features = ["proc-macro"] } quote = { version = "1.0.41", default-features = false, features = ["proc-macro"] } rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] } @@ -138,7 +141,13 @@ regex = { version = "1.12.2", default-features = false } reqwest = { version = "0.12.24", default-features = false, features = ["http2", "rustls-tls", "json"] } rust_decimal = { version = "1.39.0", default-features = false, features = ["std", "serde-str"] } rust_decimal_macros = { version = "1.39.0", default-features = false } -schemars = { version = "0.9.0", default-features = false, features = ["derive", "preserve_order", "uuid1", "url2"] } +schemars = { version = "0.9.0", default-features = false, features = [ + "chrono04", + "derive", + "preserve_order", + "uuid1", + "url2", +] } serde = { version = "1.0.228", default-features = false, features = ["derive", "std"] } serde_json = { version = "1.0.145", default-features = false, features = ["std"] } sha2 = { version = "0.10.9", default-features = false } diff --git a/academy/Cargo.toml b/academy/Cargo.toml index a48e9372..dc6e36e0 100644 --- a/academy/Cargo.toml +++ b/academy/Cargo.toml @@ -19,6 +19,7 @@ academy_config.workspace = true academy_core_coin_contracts.workspace = true academy_core_coin_impl.workspace = true academy_core_config_impl.workspace = true +academy_core_daily_rewards_impl.workspace = true academy_core_contact_impl.workspace = true academy_core_finance_contracts.workspace = true academy_core_finance_impl.workspace = true diff --git a/academy/src/commands/serve.rs b/academy/src/commands/serve.rs index ea7aedba..bd252a3f 100644 --- a/academy/src/commands/serve.rs +++ b/academy/src/commands/serve.rs @@ -8,7 +8,10 @@ use tracing::{info, warn}; use crate::{ cache, database, email, - environment::{ConfigProvider, Provider, types::RestServer}, + environment::{ + ConfigProvider, Provider, + types::{DailyRewardActivity, RestServer}, + }, }; pub async fn serve(config: Config) -> anyhow::Result<()> { @@ -55,7 +58,21 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { email.ping().await?; let config_provider = ConfigProvider::new(&config)?; - let mut provider = Provider::new(config_provider, database, cache, email); + let activity_configs = config_provider.daily_reward_activity_configs(); + let daily_reward_activity = DailyRewardActivity::new( + activity_configs.skills, + activity_configs.challenges, + activity_configs.skills_recommendations, + ) + .await?; + + let mut provider = Provider::new( + config_provider, + database, + cache, + email, + daily_reward_activity, + ); let server: RestServer = provider.provide(); server.serve().await diff --git a/academy/src/commands/tasks/mod.rs b/academy/src/commands/tasks/mod.rs index 65e0340f..0f00c651 100644 --- a/academy/src/commands/tasks/mod.rs +++ b/academy/src/commands/tasks/mod.rs @@ -1,14 +1,16 @@ use academy_config::Config; use academy_core_premium_contracts::premium::PremiumService; use academy_di::Provide; +use academy_models::user::UserId; use academy_persistence_contracts::{ Database, Transaction, premium::PremiumRepository, session::SessionRepository, }; use academy_persistence_postgres::session::PostgresSessionRepository; use anyhow::Context; -use chrono::Utc; +use chrono::{NaiveDate, Utc}; use clap::Subcommand; use tracing::info; +use uuid::Uuid; use crate::{ database, @@ -21,6 +23,15 @@ pub enum TaskCommand { PruneDatabase, /// Refresh premium subscriptions. RefreshPremium, + /// Rebuild a user's daily rewards snapshot. + DailyRewardsRebuild { + /// Target user ID. + #[clap(long = "user")] + user: Uuid, + /// UTC date (YYYY-MM-DD). Defaults to today. + #[clap(long = "date")] + date: Option, + }, } impl TaskCommand { @@ -28,6 +39,9 @@ impl TaskCommand { match self { TaskCommand::PruneDatabase => prune_database(config).await, TaskCommand::RefreshPremium => refresh_premium(config).await, + TaskCommand::DailyRewardsRebuild { user, date } => { + daily_rewards_rebuild(config, user, date).await + } } } } @@ -68,3 +82,38 @@ async fn refresh_premium(config: Config) -> anyhow::Result<()> { Ok(()) } + +async fn daily_rewards_rebuild( + config: Config, + user: Uuid, + date: Option, +) -> anyhow::Result<()> { + let mut provider = Provider::from_config(&config).await?; + + let feature: types::DailyRewardFeature = provider.provide(); + + let user_id = UserId::from(user); + let target_date = date.unwrap_or_else(|| Utc::now().date_naive()); + + let snapshot = feature + .rebuild_snapshot(user_id, target_date) + .await + .context("Failed to rebuild daily rewards snapshot")?; + + let statuses: Vec<_> = snapshot + .rewards + .iter() + .map(|reward| format!("{:?}:{:?}", reward.category, reward.status)) + .collect(); + + info!( + user_id = ?user_id, + date = %target_date, + available_coins = snapshot.claim_totals.available_coins, + claimed_today = snapshot.claim_totals.claimed_today, + rewards = ?statuses, + "Rebuilt daily rewards snapshot" + ); + + Ok(()) +} diff --git a/academy/src/environment/mod.rs b/academy/src/environment/mod.rs index 3a351045..b903c9d0 100644 --- a/academy/src/environment/mod.rs +++ b/academy/src/environment/mod.rs @@ -2,8 +2,13 @@ use std::{collections::HashMap, sync::Arc}; use academy_api_rest::{RestServerConfig, RestServerRealIpConfig}; use academy_auth_impl::AuthServiceConfig; -use academy_config::Config; +use academy_config::{Config, DailyRewardsPostgresConfig, DailyRewardsSkillsRecommendationsConfig}; use academy_core_contact_impl::ContactFeatureConfig; +use academy_core_daily_rewards_impl::{ + ChallengesActivityConfig, DailyRewardActivityServiceImpl, + DailyRewardCoinsConfig as CoreDailyRewardCoinsConfig, DailyRewardFeatureConfig, + SkillsActivityConfig, SkillsRecommendationConfig, +}; use academy_core_finance_impl::FinanceFeatureConfig; use academy_core_health_impl::HealthFeatureConfig; use academy_core_heart_impl::HeartFeatureConfig; @@ -24,16 +29,24 @@ use academy_shared_impl::{ totp::TotpServiceConfig, }; use anyhow::Context; -use types::{Cache, Database, Email}; +use types::{Cache, DailyRewardActivity, Database, Email}; pub mod types; +#[derive(Debug, Clone)] +pub struct DailyRewardActivityConfigs { + pub skills: Option, + pub challenges: Option, + pub skills_recommendations: Option, +} + provider! { /// The default provider, capable of providing all the dependencies pub Provider { database: Database, cache: Cache, email: Email, + daily_reward_activity: DailyRewardActivity, ..config: ConfigProvider { // API RestServerConfig, @@ -60,19 +73,28 @@ provider! { UserFeatureConfig, PaypalFeatureConfig, FinanceFeatureConfig, + DailyRewardFeatureConfig, HeartFeatureConfig, PremiumFeatureConfig, + DailyRewardActivityConfigs, } } } impl Provider { - pub fn new(config: ConfigProvider, database: Database, cache: Cache, email: Email) -> Self { + pub fn new( + config: ConfigProvider, + database: Database, + cache: Cache, + email: Email, + daily_reward_activity: DailyRewardActivity, + ) -> Self { Self { _cache: Default::default(), database, cache, email, + daily_reward_activity, config, } } @@ -90,7 +112,22 @@ impl Provider { .await .context("Failed to connect to email server")?; - Ok(Self::new(config_provider, database, cache, email)) + let activity_configs = config_provider.daily_reward_activity_configs(); + let daily_reward_activity = DailyRewardActivityServiceImpl::new( + activity_configs.skills, + activity_configs.challenges, + activity_configs.skills_recommendations, + ) + .await + .context("Failed to initialise daily reward activity service")?; + + Ok(Self::new( + config_provider, + database, + cache, + email, + daily_reward_activity, + )) } } @@ -124,6 +161,8 @@ provider! { finance_feature_config: FinanceFeatureConfig, heart_feature_config: HeartFeatureConfig, premium_feature_config: PremiumFeatureConfig, + daily_reward_feature_config: DailyRewardFeatureConfig, + daily_reward_activity_configs: DailyRewardActivityConfigs, } } @@ -248,6 +287,37 @@ impl ConfigProvider { purchase_range: config.coin.purchase_min..=config.coin.purchase_max, }; + let daily_reward_feature_config = DailyRewardFeatureConfig { + enable: config.daily_rewards.enable, + coins: CoreDailyRewardCoinsConfig { + arrival: config.daily_rewards.coins.arrival, + lecture: config.daily_rewards.coins.lecture, + practice: config.daily_rewards.coins.practice, + lab: config.daily_rewards.coins.lab, + }, + cache_ttl: config.daily_rewards.cache_ttl.map(Into::into), + }; + + let daily_reward_activity_configs = DailyRewardActivityConfigs { + skills: config + .daily_rewards + .activity_sources + .skills + .as_ref() + .map(map_daily_rewards_source), + challenges: config + .daily_rewards + .activity_sources + .challenges + .as_ref() + .map(map_daily_rewards_source), + skills_recommendations: config + .daily_rewards + .recommendations + .skills + .as_ref() + .map(map_daily_rewards_recommendations), + }; let finance_feature_config = FinanceFeatureConfig { vat_percent: config.finance.vat_percent, invoices_archive: config.finance.invoices_archive.clone().into(), @@ -293,11 +363,37 @@ impl ConfigProvider { session_feature_config, user_feature_config, paypal_feature_config, + daily_reward_feature_config, finance_feature_config, heart_feature_config, premium_feature_config, + daily_reward_activity_configs, }) } + + pub fn daily_reward_activity_configs(&self) -> DailyRewardActivityConfigs { + self.daily_reward_activity_configs.clone() + } +} + +fn map_daily_rewards_source(cfg: &DailyRewardsPostgresConfig) -> SkillsActivityConfig { + SkillsActivityConfig { + dsn: cfg.dsn.clone(), + max_connections: cfg.max_connections, + min_connections: cfg.min_connections, + acquire_timeout: cfg.acquire_timeout.into(), + idle_timeout: cfg.idle_timeout.map(Into::into), + max_lifetime: cfg.max_lifetime.map(Into::into), + } +} + +fn map_daily_rewards_recommendations( + cfg: &DailyRewardsSkillsRecommendationsConfig, +) -> SkillsRecommendationConfig { + SkillsRecommendationConfig { + base_url: cfg.base_url.clone(), + timeout: cfg.timeout.map(Into::into), + } } #[cfg(test)] @@ -319,7 +415,16 @@ mod tests { let cache = ValkeyCache::dummy().await; let email = EmailServiceImpl::dummy().await; - let mut provider = Provider::new(config_provider, database, cache, email); + let daily_reward_activity = DailyRewardActivityServiceImpl::new(None, None, None) + .await + .unwrap(); + let mut provider = Provider::new( + config_provider, + database, + cache, + email, + daily_reward_activity, + ); let _: RestServer = provider.provide(); } } diff --git a/academy/src/environment/types.rs b/academy/src/environment/types.rs index a9bcb02b..310801fe 100644 --- a/academy/src/environment/types.rs +++ b/academy/src/environment/types.rs @@ -8,6 +8,10 @@ use academy_cache_valkey::ValkeyCache; use academy_core_coin_impl::{CoinFeatureServiceImpl, coin::CoinServiceImpl}; use academy_core_config_impl::ConfigFeatureServiceImpl; use academy_core_contact_impl::ContactFeatureServiceImpl; +use academy_core_daily_rewards_impl::{ + DailyRewardActivityServiceImpl, + DailyRewardFeatureServiceImpl as CoreDailyRewardFeatureServiceImpl, +}; use academy_core_finance_impl::{ FinanceFeatureServiceImpl, coin::FinanceCoinServiceImpl, invoice::FinanceInvoiceServiceImpl, }; @@ -42,10 +46,10 @@ use academy_extern_impl::{ render::RenderApiServiceImpl, vat::VatApiServiceImpl, }; use academy_persistence_postgres::{ - PostgresDatabase, coin::PostgresCoinRepository, heart::PostgresHeartRepository, - mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository, paypal::PostgresPaypalRepository, - premium::PostgresPremiumRepository, session::PostgresSessionRepository, - user::PostgresUserRepository, + PostgresDatabase, coin::PostgresCoinRepository, daily_rewards::PostgresDailyRewardRepository, + heart::PostgresHeartRepository, mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository, + paypal::PostgresPaypalRepository, premium::PostgresPremiumRepository, + session::PostgresSessionRepository, user::PostgresUserRepository, }; use academy_shared_impl::{ captcha::CaptchaServiceImpl, fs::FsServiceImpl, hash::HashServiceImpl, id::IdServiceImpl, @@ -64,6 +68,7 @@ pub type RestServer = academy_api_rest::RestServer< MfaFeature, OAuth2Feature, CoinFeature, + DailyRewardFeature, PaypalFeature, FinanceFeature, HeartFeature, @@ -111,6 +116,7 @@ pub type CoinRepo = PostgresCoinRepository; pub type PaypalRepo = PostgresPaypalRepository; pub type HeartRepo = PostgresHeartRepository; pub type PremiumRepo = PostgresPremiumRepository; +pub type DailyRewardRepo = PostgresDailyRewardRepository; // Auth pub type Auth = @@ -231,4 +237,16 @@ pub type PremiumPlan = PremiumPlanServiceImpl; pub type Premium = PremiumServiceImpl; pub type PremiumPurchase = PremiumPurchaseServiceImpl; +pub type DailyRewardFeature = CoreDailyRewardFeatureServiceImpl< + Database, + Auth, + DailyRewardRepo, + Coin, + Cache, + DailyRewardActivity, + Id, + Time, +>; +pub type DailyRewardActivity = DailyRewardActivityServiceImpl; + pub type Internal = InternalServiceImpl; diff --git a/academy_api/rest/Cargo.toml b/academy_api/rest/Cargo.toml index d5da0578..4bd9c195 100644 --- a/academy_api/rest/Cargo.toml +++ b/academy_api/rest/Cargo.toml @@ -13,6 +13,7 @@ workspace = true academy_assets.workspace = true academy_auth_contracts.workspace = true academy_core_coin_contracts.workspace = true +academy_core_daily_rewards_contracts.workspace = true academy_core_config_contracts.workspace = true academy_core_contact_contracts.workspace = true academy_core_finance_contracts.workspace = true @@ -41,6 +42,7 @@ axum.workspace = true base64.workspace = true futures.workspace = true mime.workspace = true +chrono.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true diff --git a/academy_api/rest/src/lib.rs b/academy_api/rest/src/lib.rs index 2147f96e..b028fbd5 100644 --- a/academy_api/rest/src/lib.rs +++ b/academy_api/rest/src/lib.rs @@ -6,6 +6,7 @@ use std::{ use academy_core_coin_contracts::CoinFeatureService; use academy_core_config_contracts::ConfigFeatureService; use academy_core_contact_contracts::ContactFeatureService; +use academy_core_daily_rewards_contracts::DailyRewardFeatureService; use academy_core_finance_contracts::FinanceFeatureService; use academy_core_health_contracts::HealthFeatureService; use academy_core_heart_contracts::HeartFeatureService; @@ -53,6 +54,7 @@ pub struct RestServer< Mfa, OAuth2, Coin, + DailyReward, Paypal, Finance, Heart, @@ -68,6 +70,7 @@ pub struct RestServer< mfa: Mfa, oauth2: OAuth2, coin: Coin, + daily_reward: DailyReward, paypal: Paypal, finance: Finance, heart: Heart, @@ -97,6 +100,7 @@ impl< Mfa, OAuth2, Coin, + DailyReward, Paypal, Finance, Heart, @@ -112,6 +116,7 @@ impl< Mfa, OAuth2, Coin, + DailyReward, Paypal, Finance, Heart, @@ -127,6 +132,7 @@ where Mfa: MfaFeatureService, OAuth2: OAuth2FeatureService, Coin: CoinFeatureService, + DailyReward: DailyRewardFeatureService, Paypal: PaypalFeatureService, Finance: FinanceFeatureService, Heart: HeartFeatureService, @@ -158,6 +164,7 @@ where routes::mfa::TAG, routes::oauth2::TAG, routes::coin::TAG, + routes::daily_rewards::TAG, routes::paypal::TAG, routes::finance::TAG, routes::heart::TAG, @@ -235,6 +242,7 @@ where .merge(routes::mfa::router(self.mfa.into())) .merge(routes::oauth2::router(self.oauth2.into())) .merge(routes::coin::router(self.coin.into())) + .merge(routes::daily_rewards::router(self.daily_reward.into())) .merge(routes::paypal::router(self.paypal.into())) .merge(routes::finance::router(self.finance.into())) .merge(routes::heart::router(self.heart.into())) diff --git a/academy_api/rest/src/models/daily_rewards.rs b/academy_api/rest/src/models/daily_rewards.rs new file mode 100644 index 00000000..3b3948ff --- /dev/null +++ b/academy_api/rest/src/models/daily_rewards.rs @@ -0,0 +1,126 @@ +use std::str::FromStr; + +use academy_core_daily_rewards_contracts::{ + DailyRewardCategory, DailyRewardClaimAllResponse as CoreClaimAllResponse, + DailyRewardClaimResponse as CoreClaimResponse, DailyRewardClaimSkip as CoreClaimSkip, + DailyRewardClaimSkipReason as CoreSkipReason, DailyRewardClaimSuccess as CoreClaimSuccess, +}; +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ApiDailyRewardClaimResponse { + pub status: &'static str, + pub success: ApiDailyRewardClaimSuccess, +} + +impl ApiDailyRewardClaimResponse { + pub const STATUS_OK: &'static str = "ok"; +} + +impl From for ApiDailyRewardClaimResponse { + fn from(response: CoreClaimResponse) -> Self { + Self { + status: Self::STATUS_OK, + success: ApiDailyRewardClaimSuccess::from(response.success), + } + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ApiDailyRewardClaimSuccess { + pub category: DailyRewardCategory, + pub coins: i32, + pub claimed_at: DateTime, +} + +impl From for ApiDailyRewardClaimSuccess { + fn from(success: CoreClaimSuccess) -> Self { + Self { + category: success.category, + coins: success.coins, + claimed_at: success.claimed_at, + } + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ApiDailyRewardClaimAllResponse { + pub status: &'static str, + pub claimed: Vec, + pub skipped_categories: Vec, +} + +impl ApiDailyRewardClaimAllResponse { + pub const STATUS_OK: &'static str = "ok"; +} + +impl From for ApiDailyRewardClaimAllResponse { + fn from(response: CoreClaimAllResponse) -> Self { + let claimed = response + .claimed + .into_iter() + .map(ApiDailyRewardClaimSuccess::from) + .collect(); + let skipped = response + .skipped + .into_iter() + .map(ApiDailyRewardClaimSkip::from) + .collect(); + + Self { + status: Self::STATUS_OK, + claimed, + skipped_categories: skipped, + } + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ApiDailyRewardClaimSkip { + pub category: DailyRewardCategory, + #[serde(rename = "reason")] + pub reason: ApiDailyRewardClaimSkipReason, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApiDailyRewardClaimSkipReason { + Pending, + Unavailable, + AlreadyClaimed, + Error, +} + +impl From for ApiDailyRewardClaimSkip { + fn from(skip: CoreClaimSkip) -> Self { + Self { + category: skip.category, + reason: map_skip_reason(skip.reason), + } + } +} + +fn map_skip_reason(reason: CoreSkipReason) -> ApiDailyRewardClaimSkipReason { + match reason { + CoreSkipReason::Pending => ApiDailyRewardClaimSkipReason::Pending, + CoreSkipReason::Unavailable => ApiDailyRewardClaimSkipReason::Unavailable, + CoreSkipReason::AlreadyClaimed => ApiDailyRewardClaimSkipReason::AlreadyClaimed, + CoreSkipReason::Error => ApiDailyRewardClaimSkipReason::Error, + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PathDailyRewardCategory { + #[serde(deserialize_with = "deserialize_category")] + pub category: DailyRewardCategory, +} + +fn deserialize_category<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + DailyRewardCategory::from_str(&raw).map_err(serde::de::Error::custom) +} diff --git a/academy_api/rest/src/models/mod.rs b/academy_api/rest/src/models/mod.rs index f291485d..9a93a0c2 100644 --- a/academy_api/rest/src/models/mod.rs +++ b/academy_api/rest/src/models/mod.rs @@ -8,6 +8,7 @@ use crate::const_schema; pub mod coin; pub mod contact; +pub mod daily_rewards; pub mod heart; pub mod oauth2; pub mod premium; diff --git a/academy_api/rest/src/routes/daily_rewards.rs b/academy_api/rest/src/routes/daily_rewards.rs new file mode 100644 index 00000000..9d1b64d2 --- /dev/null +++ b/academy_api/rest/src/routes/daily_rewards.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use academy_core_daily_rewards_contracts::{ + DailyRewardClaimAllError, DailyRewardClaimError, DailyRewardFeatureService, + DailyRewardGetError, DailyRewardsSnapshot, +}; +use aide::{ + axum::{ApiRouter, routing}, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + docs::TransformOperationExt, + error_code, + errors::{auth_error, auth_error_docs, internal_server_error, internal_server_error_docs}, + extractors::auth::ApiToken, + models::daily_rewards::{ + ApiDailyRewardClaimAllResponse, ApiDailyRewardClaimResponse, PathDailyRewardCategory, + }, +}; + +pub const TAG: &str = "DailyRewards"; + +pub fn router(service: Arc) -> ApiRouter<()> { + ApiRouter::new() + .api_route("/daily-rewards", routing::get_with(get, get_docs)) + .api_route( + "/daily-rewards/{category}/claim", + routing::post_with(claim, claim_docs), + ) + .api_route( + "/daily-rewards/claim-all", + routing::post_with(claim_all, claim_all_docs), + ) + .with_state(service) + .with_path_items(|op| op.tag(TAG)) +} + +async fn get( + State(service): State>, + token: ApiToken, +) -> Response { + match service.get_today(&token.0).await { + Ok(response) => Json(response.snapshot).into_response(), + Err(DailyRewardGetError::FeatureDisabled) => FeatureDisabledError.into_response(), + Err(DailyRewardGetError::Auth(err)) => auth_error(err), + Err(DailyRewardGetError::Other(err)) => internal_server_error(err), + } +} + +fn get_docs(op: TransformOperation) -> TransformOperation { + op.summary("Return the daily rewards snapshot for the authenticated user.") + .add_response::(StatusCode::OK, None) + .add_error::() + .with(auth_error_docs) + .with(internal_server_error_docs) +} + +async fn claim( + State(service): State>, + token: ApiToken, + Path(PathDailyRewardCategory { category }): Path, +) -> Response { + match service.claim(&token.0, category).await { + Ok(result) => Json(ApiDailyRewardClaimResponse::from(result)).into_response(), + Err(DailyRewardClaimError::FeatureDisabled) => FeatureDisabledError.into_response(), + Err(DailyRewardClaimError::Auth(err)) => auth_error(err), + Err(DailyRewardClaimError::NotReady) => RewardNotReadyError.into_response(), + Err(DailyRewardClaimError::Unavailable) => RewardUnavailableError.into_response(), + Err(DailyRewardClaimError::AlreadyClaimed) => RewardAlreadyClaimedError.into_response(), + Err(DailyRewardClaimError::Other(err)) => internal_server_error(err), + } +} + +fn claim_docs(op: TransformOperation) -> TransformOperation { + op.summary("Claim a single daily reward category.") + .add_response::(StatusCode::OK, None) + .add_error::() + .add_error::() + .add_error::() + .add_error::() + .with(auth_error_docs) + .with(internal_server_error_docs) +} + +async fn claim_all( + State(service): State>, + token: ApiToken, +) -> Response { + match service.claim_all(&token.0).await { + Ok(result) => Json(ApiDailyRewardClaimAllResponse::from(result)).into_response(), + Err(DailyRewardClaimAllError::FeatureDisabled) => FeatureDisabledError.into_response(), + Err(DailyRewardClaimAllError::Auth(err)) => auth_error(err), + Err(DailyRewardClaimAllError::Other(err)) => internal_server_error(err), + } +} + +fn claim_all_docs(op: TransformOperation) -> TransformOperation { + op.summary("Claim all available daily rewards.") + .add_response::(StatusCode::OK, None) + .add_error::() + .with(auth_error_docs) + .with(internal_server_error_docs) +} + +error_code! { + /// The daily rewards feature is disabled for this environment. + pub FeatureDisabledError(NOT_FOUND, "daily_rewards_feature_disabled"); + /// The reward cannot be claimed yet. + pub RewardNotReadyError(CONFLICT, "reward_not_ready"); + /// The reward is currently unavailable. + pub RewardUnavailableError(CONFLICT, "reward_unavailable"); + /// The reward has already been claimed. + pub RewardAlreadyClaimedError(CONFLICT, "reward_already_claimed"); +} diff --git a/academy_api/rest/src/routes/mod.rs b/academy_api/rest/src/routes/mod.rs index 4c68f943..0ed225a6 100644 --- a/academy_api/rest/src/routes/mod.rs +++ b/academy_api/rest/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod coin; pub mod config; pub mod contact; +pub mod daily_rewards; pub mod finance; pub mod health; pub mod heart; diff --git a/academy_config/src/lib.rs b/academy_config/src/lib.rs index 432d7f13..cde41935 100644 --- a/academy_config/src/lib.rs +++ b/academy_config/src/lib.rs @@ -93,6 +93,7 @@ pub struct Config { pub vat: VatConfig, pub paypal: PaypalConfig, pub coin: CoinConfig, + pub daily_rewards: DailyRewardsConfig, pub heart: HeartConfig, pub premium: PremiumConfig, pub render: RenderConfig, @@ -224,6 +225,62 @@ pub struct CoinConfig { pub purchase_max: u64, } +#[derive(Debug, Deserialize)] +pub struct DailyRewardsConfig { + pub enable: bool, + pub coins: DailyRewardsCoinsConfig, + #[serde(default)] + pub cache_ttl: Option, + #[serde(default)] + pub activity_sources: DailyRewardsActivitySourcesConfig, + #[serde(default)] + pub recommendations: DailyRewardsRecommendationsConfig, +} + +#[derive(Debug, Deserialize)] +pub struct DailyRewardsCoinsConfig { + pub arrival: i32, + pub lecture: i32, + pub practice: i32, + pub lab: i32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DailyRewardsActivitySourcesConfig { + #[serde(default)] + pub skills: Option, + #[serde(default)] + pub challenges: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DailyRewardsRecommendationsConfig { + #[serde(default)] + pub skills: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DailyRewardsPostgresConfig { + pub dsn: String, + #[serde(default = "default_max_connections")] + pub max_connections: u32, + #[serde(default = "default_min_connections")] + pub min_connections: u32, + #[serde(default = "default_acquire_timeout")] + pub acquire_timeout: Duration, + #[serde(default)] + pub idle_timeout: Option, + #[serde(default)] + pub max_lifetime: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DailyRewardsSkillsRecommendationsConfig { + pub base_url: String, + #[serde(default)] + pub timeout: Option, +} + #[derive(Debug, Deserialize)] pub struct HeartConfig { pub max: u64, @@ -276,6 +333,18 @@ pub struct OAuth2ProviderConfig { pub scopes: Vec, } +fn default_max_connections() -> u32 { + 5 +} + +fn default_min_connections() -> u32 { + 1 +} + +fn default_acquire_timeout() -> Duration { + Duration(std::time::Duration::from_secs(30)) +} + #[cfg(test)] mod tests { #[test] diff --git a/academy_core/daily_rewards/contracts/Cargo.toml b/academy_core/daily_rewards/contracts/Cargo.toml new file mode 100644 index 00000000..29fa0f31 --- /dev/null +++ b/academy_core/daily_rewards/contracts/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "academy_core_daily_rewards_contracts" +version.workspace = true +edition.workspace = true +publish.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[features] +mock = ["dep:mockall"] + +[dependencies] +academy_models.workspace = true +anyhow.workspace = true +chrono.workspace = true +mockall = { workspace = true, optional = true } +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/academy_core/daily_rewards/contracts/src/lib.rs b/academy_core/daily_rewards/contracts/src/lib.rs new file mode 100644 index 00000000..11c78019 --- /dev/null +++ b/academy_core/daily_rewards/contracts/src/lib.rs @@ -0,0 +1,184 @@ +use std::future::Future; + +use academy_models::{ + auth::{AccessToken, AuthError}, + user::UserId, +}; +use chrono::{DateTime, NaiveDate, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DailyRewardStatus { + Pending, + Ready, + Claimed, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DailyRewardUnavailableReason { + NoRecommendation, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardItem { + pub category: DailyRewardCategory, + pub coins: i32, + pub status: DailyRewardStatus, + pub claimable_since: Option>, + pub last_detected_at: Option>, + pub claimed_at: Option>, + pub activity_sample: Option, + pub unavailable_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardClaimTotals { + pub available_coins: i32, + pub claimed_today: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardsSnapshot { + pub date_utc: NaiveDate, + pub feature_enabled: bool, + pub rewards: Vec, + pub claim_totals: DailyRewardClaimTotals, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardClaimSuccess { + pub category: DailyRewardCategory, + pub coins: i32, + pub claimed_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DailyRewardClaimSkipReason { + Pending, + Unavailable, + AlreadyClaimed, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardClaimSkip { + pub category: DailyRewardCategory, + pub reason: DailyRewardClaimSkipReason, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardClaimAllResponse { + pub claimed: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardClaimResponse { + pub success: DailyRewardClaimSuccess, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardGetResponse { + pub snapshot: DailyRewardsSnapshot, +} + +#[derive(Debug, Error)] +pub enum DailyRewardGetError { + #[error(transparent)] + Auth(#[from] AuthError), + #[error("Daily rewards feature is disabled.")] + FeatureDisabled, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum DailyRewardClaimError { + #[error(transparent)] + Auth(#[from] AuthError), + #[error("Daily rewards feature is disabled.")] + FeatureDisabled, + #[error("Reward is not ready to claim.")] + NotReady, + #[error("Reward has already been claimed.")] + AlreadyClaimed, + #[error("Reward is currently unavailable.")] + Unavailable, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum DailyRewardClaimAllError { + #[error(transparent)] + Auth(#[from] AuthError), + #[error("Daily rewards feature is disabled.")] + FeatureDisabled, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[cfg_attr(feature = "mock", mockall::automock)] +pub trait DailyRewardFeatureService: Send + Sync + 'static { + fn get_today( + &self, + token: &AccessToken, + ) -> impl Future> + Send; + + fn claim( + &self, + token: &AccessToken, + category: DailyRewardCategory, + ) -> impl Future> + Send; + + fn claim_all( + &self, + token: &AccessToken, + ) -> impl Future> + Send; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardActivity { + pub first_detected_at: DateTime, + pub last_detected_at: DateTime, + pub activity_sample: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardActivityState { + pub detected: Option, + pub pending_sample: Option, + pub unavailable_reason: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardActivitySnapshot { + pub lecture: DailyRewardActivityState, + pub practice: DailyRewardActivityState, + pub lab: DailyRewardActivityState, +} + +#[cfg_attr(feature = "mock", mockall::automock)] +pub trait DailyRewardActivityService: Send + Sync + 'static { + #[allow( + clippy::needless_lifetimes, + reason = "mockall requires explicit lifetime parameter" + )] + fn detect<'a>( + &self, + token: Option<&'a AccessToken>, + user_id: UserId, + day_start: DateTime, + day_end: DateTime, + ) -> impl Future> + Send; +} + +pub use academy_models::daily_rewards::{DailyRewardCategory, DailyRewardEntry}; diff --git a/academy_core/daily_rewards/impl/Cargo.toml b/academy_core/daily_rewards/impl/Cargo.toml new file mode 100644 index 00000000..a2f07da0 --- /dev/null +++ b/academy_core/daily_rewards/impl/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "academy_core_daily_rewards_impl" +version.workspace = true +edition.workspace = true +publish.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +academy_auth_contracts.workspace = true +academy_cache_contracts.workspace = true +academy_core_coin_contracts.workspace = true +academy_core_daily_rewards_contracts.workspace = true +academy_di.workspace = true +academy_models.workspace = true +academy_persistence_contracts.workspace = true +academy_shared_contracts.workspace = true +academy_utils.workspace = true +anyhow.workspace = true +bb8 = { version = "0.9.0", default-features = false } +bb8-postgres = { version = "0.9.0", default-features = false, features = [ + "with-chrono-0_4", + "with-uuid-1", + "with-serde_json-1", +] } +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio-postgres = { version = "0.7.15", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } +tracing.workspace = true +uuid.workspace = true +reqwest.workspace = true + +[dev-dependencies] +academy_auth_contracts = { workspace = true, features = ["mock"] } +academy_cache_contracts = { workspace = true, features = ["mock"] } +academy_core_coin_contracts = { workspace = true, features = ["mock"] } +academy_core_daily_rewards_contracts = { workspace = true, features = ["mock"] } +academy_models.workspace = true +academy_persistence_contracts = { workspace = true, features = ["mock"] } +academy_shared_contracts = { workspace = true, features = ["mock"] } +serde_json.workspace = true +tokio.workspace = true +mockall.workspace = true diff --git a/academy_core/daily_rewards/impl/src/activity.rs b/academy_core/daily_rewards/impl/src/activity.rs new file mode 100644 index 00000000..c9898721 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/activity.rs @@ -0,0 +1,1433 @@ +use std::time::Duration; + +use academy_core_daily_rewards_contracts::{ + DailyRewardActivity, DailyRewardActivityService, DailyRewardActivitySnapshot, + DailyRewardActivityState, DailyRewardUnavailableReason, +}; +use academy_models::{auth::AccessToken, user::UserId}; +use anyhow::{Context, Result, anyhow}; +use bb8::{Pool, PooledConnection}; +use bb8_postgres::PostgresConnectionManager; +use chrono::{DateTime, NaiveDateTime, Utc}; +use reqwest::{Client, StatusCode, Url}; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; +use tokio_postgres::{NoTls, Row, error::SqlState}; +use tracing::warn; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct PostgresActivityConfig { + pub dsn: String, + pub max_connections: u32, + pub min_connections: u32, + pub acquire_timeout: Duration, + pub idle_timeout: Option, + pub max_lifetime: Option, +} + +pub type SkillsActivityConfig = PostgresActivityConfig; +pub type ChallengesActivityConfig = PostgresActivityConfig; + +#[derive(Debug, Clone)] +pub struct SkillsRecommendationConfig { + pub base_url: String, + pub timeout: Option, +} + +#[derive(Debug, Clone)] +struct SkillsRecommendationClient { + client: Client, + base_url: Url, +} + +impl SkillsRecommendationClient { + fn new(config: &SkillsRecommendationConfig) -> Result { + let base_url = Url::parse(&config.base_url) + .context("Failed to parse skills recommendations base URL")?; + + let mut builder = Client::builder(); + if let Some(timeout) = config.timeout { + builder = builder.timeout(timeout); + } + + let client = builder + .build() + .context("Failed to initialise skills recommendations HTTP client")?; + + Ok(Self { client, base_url }) + } + + async fn ext_lecture(&self, token: &AccessToken) -> Result> { + self.fetch_payload(token, "courses/next/lecture").await + } + + async fn ext_task(&self, token: &AccessToken) -> Result> { + self.fetch_payload(token, "courses/next/task").await + } + + async fn ext_lab(&self, token: &AccessToken) -> Result> { + self.fetch_payload(token, "courses/next/lab").await + } + + async fn fetch_payload(&self, token: &AccessToken, path: &str) -> Result> + where + T: DeserializeOwned, + { + let url = self + .base_url + .join(path) + .context("Failed to build skills recommendation URL")?; + + let response = self + .client + .get(url) + .bearer_auth(token.as_str()) + .send() + .await + .context("Failed to fetch skills recommendation")?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(None), + _ => { + let response = response + .error_for_status() + .context("Skills recommendation request failed")?; + let payload = response + .json::() + .await + .context("Failed to deserialize skills recommendation response")?; + Ok(Some(payload)) + } + } + } +} + +trait TimestampColumnAccessor { + fn try_get_datetime_utc(&self, column: &str) -> Result>>; + fn try_get_naive_datetime(&self, column: &str) -> Result>; +} + +struct PgTimestampRow<'a> { + row: &'a Row, +} + +impl<'a> TimestampColumnAccessor for PgTimestampRow<'a> { + fn try_get_datetime_utc(&self, column: &str) -> Result>> { + self.row + .try_get::<_, Option>>(column) + .map_err(Into::into) + } + + fn try_get_naive_datetime(&self, column: &str) -> Result> { + self.row + .try_get::<_, Option>(column) + .map_err(Into::into) + } +} + +fn read_optional_timestamp(row: &R, column: &str) -> Result>> +where + R: TimestampColumnAccessor + ?Sized, +{ + match row.try_get_datetime_utc(column) { + Ok(value) => Ok(value), + Err(first_err) => match row.try_get_naive_datetime(column) { + Ok(naive) => Ok(naive.map(|ts| DateTime::from_naive_utc_and_offset(ts, Utc))), + Err(_) => Err(first_err), + }, + } +} + +fn read_required_timestamp(row: &R, column: &str) -> Result> +where + R: TimestampColumnAccessor + ?Sized, +{ + read_optional_timestamp(row, column)? + .ok_or_else(|| anyhow!("column `{}` returned NULL timestamp", column)) +} + +fn build_lecture_sample( + course_id: String, + lecture_id: String, + metadata: Option, +) -> Value { + let mut sample = Map::new(); + sample.insert("course_id".into(), Value::String(course_id.clone())); + duplicate_string_field(&mut sample, "course_id", "courseId"); + sample.insert("lecture_id".into(), Value::String(lecture_id.clone())); + duplicate_string_field(&mut sample, "lecture_id", "lectureId"); + + if let Some(metadata) = metadata { + insert_optional_string(&mut sample, "course_title", metadata.course_title); + duplicate_string_field(&mut sample, "course_title", "courseTitle"); + insert_optional_string(&mut sample, "section_id", metadata.section_id.clone()); + duplicate_string_field(&mut sample, "section_id", "sectionId"); + insert_optional_string(&mut sample, "section_title", metadata.section_title); + duplicate_string_field(&mut sample, "section_title", "sectionTitle"); + insert_optional_string(&mut sample, "lecture_title", metadata.lecture_title); + duplicate_string_field(&mut sample, "lecture_title", "lectureTitle"); + } + + Value::Object(sample) +} + +fn build_subtask_sample(input: SubtaskSampleInput, is_lab: bool) -> Value { + let SubtaskSampleInput { + task_id, + subtask_id, + subtask_type, + challenge_title, + category_id, + skill_ids, + course_id, + section_id, + lecture_id, + } = input; + + let mut sample = Map::new(); + sample.insert("task_id".into(), Value::String(task_id.clone())); + duplicate_string_field(&mut sample, "task_id", "taskId"); + sample.insert("subtask_id".into(), Value::String(subtask_id.clone())); + duplicate_string_field(&mut sample, "subtask_id", "subtaskId"); + sample.insert("subtask_type".into(), Value::String(subtask_type.clone())); + duplicate_string_field(&mut sample, "subtask_type", "subtaskType"); + + insert_optional_string(&mut sample, "course_id", course_id.clone()); + duplicate_string_field(&mut sample, "course_id", "courseId"); + insert_optional_string(&mut sample, "section_id", section_id.clone()); + duplicate_string_field(&mut sample, "section_id", "sectionId"); + insert_optional_string(&mut sample, "lecture_id", lecture_id.clone()); + duplicate_string_field(&mut sample, "lecture_id", "lectureId"); + + if let Some(category_id) = category_id { + sample.insert("category_id".into(), Value::String(category_id.to_string())); + } + + if !skill_ids.is_empty() { + sample.insert( + "skill_ids".into(), + Value::Array(skill_ids.iter().cloned().map(Value::String).collect()), + ); + } + + if is_lab { + insert_optional_string(&mut sample, "lab_title", challenge_title); + duplicate_string_field(&mut sample, "lab_title", "labTitle"); + sample.insert("challenge_id".into(), Value::String(task_id.clone())); + duplicate_string_field(&mut sample, "challenge_id", "challengeId"); + sample.insert( + "coding_challenge_id".into(), + Value::String(subtask_id.clone()), + ); + duplicate_string_field(&mut sample, "coding_challenge_id", "codingChallengeId"); + } else { + insert_optional_string(&mut sample, "task_title", challenge_title); + duplicate_string_field(&mut sample, "task_title", "taskTitle"); + sample.insert("solve_id".into(), Value::String(task_id.clone())); + duplicate_string_field(&mut sample, "solve_id", "solveId"); + sample.insert("solvable_id".into(), Value::String(task_id.clone())); + duplicate_string_field(&mut sample, "solvable_id", "solvableId"); + sample.insert( + "quizzes_from".into(), + Value::String(derive_quizzes_from( + course_id.as_deref(), + &skill_ids, + &subtask_type, + )), + ); + duplicate_string_field(&mut sample, "quizzes_from", "quizzesFrom"); + if subtask_type.contains("matching") { + sample.insert("matching_id".into(), Value::String(subtask_id.clone())); + duplicate_string_field(&mut sample, "matching_id", "matchingId"); + } + sample.insert("query_subtask_id".into(), Value::String(subtask_id.clone())); + duplicate_string_field(&mut sample, "query_subtask_id", "querySubTaskId"); + } + + Value::Object(sample) +} + +async fn fetch_lecture_metadata( + conn: &PgConnection<'_>, + course_id: &str, + lecture_id: &str, +) -> Result> { + let row = match conn + .query_opt( + "select \ + course.title as course_title, \ + section.id as section_id, \ + section.title as section_title, \ + lecture.title as lecture_title \ + from skills_course_lectures lecture \ + join skills_course_sections section \ + on section.course_id = lecture.course_id \ + and section.id = lecture.section_id \ + join skills_courses course \ + on course.id = lecture.course_id \ + where lecture.course_id = $1 \ + and lecture.id = $2 \ + limit 1", + &[&course_id, &lecture_id], + ) + .await + { + Ok(row) => row, + Err(err) => { + if is_metadata_lookup_error(&err) { + warn!(error = %err, "Skipping lecture metadata enrichment"); + return Ok(None); + } + return Err(err.into()); + } + }; + + let metadata = row.map(|row| LectureMetadata { + course_title: row + .try_get::<_, Option>("course_title") + .unwrap_or(None) + .and_then(normalize_db_string), + section_id: row + .try_get::<_, Option>("section_id") + .unwrap_or(None) + .map(|id| id.to_string()) + .or_else(|| { + row.try_get::<_, Option>("section_id") + .unwrap_or(None) + .and_then(normalize_db_string) + }), + section_title: row + .try_get::<_, Option>("section_title") + .unwrap_or(None) + .and_then(normalize_db_string), + lecture_title: row + .try_get::<_, Option>("lecture_title") + .unwrap_or(None) + .and_then(normalize_db_string), + }); + + Ok(metadata) +} + +fn is_metadata_lookup_error(err: &tokio_postgres::Error) -> bool { + matches!( + err.code(), + Some( + &SqlState::UNDEFINED_TABLE + | &SqlState::UNDEFINED_FUNCTION + | &SqlState::UNDEFINED_COLUMN + | &SqlState::INVALID_SCHEMA_NAME + ) + ) +} + +fn insert_optional_string(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + map.insert(key.to_owned(), Value::String(value)); + } +} + +fn duplicate_string_field(map: &mut Map, source: &str, alias: &str) { + if map.contains_key(alias) { + return; + } + + if let Some(Value::String(existing)) = map.get(source) { + let alias_value = existing.clone(); + map.insert(alias.to_owned(), Value::String(alias_value)); + } +} + +fn normalize_db_string(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{NaiveDate, TimeZone}; + use serde_json::Value; + use std::cell::RefCell; + + struct FakeTimestampRow { + datetime: RefCell>>>>, + naive: RefCell>>>, + } + + impl FakeTimestampRow { + fn new( + datetime: Result>>, + naive: Result>, + ) -> Self { + Self { + datetime: RefCell::new(Some(datetime)), + naive: RefCell::new(Some(naive)), + } + } + } + + impl TimestampColumnAccessor for FakeTimestampRow { + fn try_get_datetime_utc(&self, _column: &str) -> Result>> { + self.datetime + .borrow_mut() + .take() + .expect("datetime accessor should be called once") + } + + fn try_get_naive_datetime(&self, _column: &str) -> Result> { + self.naive + .borrow_mut() + .take() + .expect("naive accessor should be called once") + } + } + + #[test] + fn read_optional_timestamp_prefers_timezone_aware_value() { + let expected = Utc.with_ymd_and_hms(2025, 11, 2, 13, 0, 0).unwrap(); + let row = FakeTimestampRow::new(Ok(Some(expected)), Err(anyhow!("naive unused"))); + + let actual = read_optional_timestamp(&row, "completed").expect("timestamp read"); + + assert_eq!(actual, Some(expected)); + } + + #[test] + fn read_optional_timestamp_falls_back_to_naive_value() { + let naive = NaiveDate::from_ymd_opt(2025, 11, 2) + .unwrap() + .and_hms_opt(12, 30, 0) + .unwrap(); + let row = FakeTimestampRow::new(Err(anyhow!("type mismatch")), Ok(Some(naive))); + + let actual = read_optional_timestamp(&row, "completed").expect("timestamp read"); + + assert_eq!( + actual, + Some(DateTime::from_naive_utc_and_offset(naive, Utc)) + ); + } + + #[test] + fn read_required_timestamp_errors_on_null() { + let row = FakeTimestampRow::new(Ok(None), Ok(None)); + + let err = read_required_timestamp(&row, "completed").expect_err("null timestamp"); + + assert!( + err.to_string() + .contains("column `completed` returned NULL timestamp"), + "expected helpful error message" + ); + } + + fn row( + solved: Option>, + subtask_type: &str, + task_id: &str, + subtask_id: &str, + challenge_title: Option<&str>, + ) -> SubtaskRow { + SubtaskRow { + solved_timestamp: solved, + task_id: task_id.to_owned(), + subtask_id: subtask_id.to_owned(), + subtask_type: subtask_type.to_owned(), + challenge_title: challenge_title.map(|title| title.to_owned()), + category_id: None, + skill_ids: Vec::new(), + course_id: Some("course-1".to_owned()), + section_id: Some("section-1".to_owned()), + lecture_id: Some("lecture-1".to_owned()), + } + } + + #[test] + fn build_subtask_activity_returns_none_without_matching_rows() { + let solved = Utc.with_ymd_and_hms(2025, 11, 1, 9, 0, 0).unwrap(); + let rows = vec![row( + Some(solved), + "coding_challenge", + "task", + "sub", + Some("Lab"), + )]; + let day_start = Utc.with_ymd_and_hms(2025, 11, 1, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + + assert!(build_subtask_activity(&rows, &["matching"], false, day_start, day_end).is_none()); + } + + #[test] + fn build_subtask_activity_tracks_first_and_last_for_practice() { + let first = Utc.with_ymd_and_hms(2025, 11, 1, 8, 0, 0).unwrap(); + let mid = Utc.with_ymd_and_hms(2025, 11, 1, 10, 30, 0).unwrap(); + let last = Utc.with_ymd_and_hms(2025, 11, 1, 14, 15, 0).unwrap(); + + let rows = vec![ + row(Some(first), "matching", "task-1", "sub-1", Some("Match A")), + row( + Some(mid), + "coding_challenge", + "task-ignored", + "sub-ignored", + None, + ), + row( + Some(last), + "multiple_choice_question", + "task-2", + "sub-2", + Some("Quiz B"), + ), + ]; + let day_start = Utc.with_ymd_and_hms(2025, 11, 1, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + + let activity = build_subtask_activity( + &rows, + &["matching", "multiple_choice_question", "question"], + false, + day_start, + day_end, + ) + .expect("activity expected"); + + assert_eq!(activity.first_detected_at, first); + assert_eq!(activity.last_detected_at, last); + + let sample_value = activity.activity_sample.expect("practice sample expected"); + let sample = sample_value.as_object().expect("sample object"); + + assert_eq!( + sample.get("task_id").and_then(Value::as_str), + Some("task-1") + ); + assert_eq!( + sample.get("matching_id").and_then(Value::as_str), + Some("sub-1") + ); + assert_eq!( + sample.get("task_title").and_then(Value::as_str), + Some("Match A") + ); + } + + #[test] + fn build_subtask_activity_builds_lab_sample() { + let solved = Utc.with_ymd_and_hms(2025, 11, 2, 12, 0, 0).unwrap(); + let mut lab_row = row( + Some(solved), + "coding_challenge", + "lab-task", + "lab-sub", + Some("FizzBuzz"), + ); + lab_row.skill_ids = vec!["rust".to_owned()]; + let day_start = Utc.with_ymd_and_hms(2025, 11, 2, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + + let activity = + build_subtask_activity(&[lab_row], &["coding_challenge"], true, day_start, day_end) + .expect("lab activity"); + + assert_eq!(activity.first_detected_at, solved); + assert_eq!(activity.last_detected_at, solved); + + let sample_value = activity.activity_sample.expect("lab sample expected"); + let sample = sample_value.as_object().expect("sample object"); + + assert_eq!( + sample.get("coding_challenge_id").and_then(Value::as_str), + Some("lab-sub") + ); + assert_eq!( + sample.get("lab_title").and_then(Value::as_str), + Some("FizzBuzz") + ); + assert_eq!( + sample + .get("skill_ids") + .and_then(Value::as_array) + .map(|ids| ids.len()), + Some(1) + ); + } + + #[test] + fn build_subtask_activity_ignores_out_of_window_entries() { + let day_start = Utc.with_ymd_and_hms(2025, 11, 3, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + let solved = day_start - chrono::Duration::hours(1); + + let rows = vec![row( + Some(solved), + "matching", + "task-out-of-window", + "sub-out-of-window", + None, + )]; + + assert!( + build_subtask_activity(&rows, &["matching"], false, day_start, day_end).is_none(), + "entries outside the day should be ignored" + ); + } + + #[test] + fn build_subtask_activity_prefers_today_entries() { + let day_start = Utc.with_ymd_and_hms(2025, 11, 4, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + let before = day_start - chrono::Duration::hours(2); + let morning = day_start + chrono::Duration::hours(1); + let evening = day_start + chrono::Duration::hours(5); + + let rows = vec![ + row( + Some(before), + "matching", + "task-before", + "sub-before", + Some("Old Match"), + ), + row( + Some(morning), + "matching", + "task-today", + "sub-today", + Some("Today Match"), + ), + row( + Some(evening), + "matching", + "task-today-late", + "sub-today-late", + Some("Evening Match"), + ), + ]; + + let activity = build_subtask_activity(&rows, &["matching"], false, day_start, day_end) + .expect("today activity expected"); + + assert_eq!(activity.first_detected_at, morning); + assert_eq!(activity.last_detected_at, evening); + + let sample_value = activity.activity_sample.expect("practice sample expected"); + let sample = sample_value.as_object().expect("sample object"); + + assert_eq!( + sample.get("matching_id").and_then(Value::as_str), + Some("sub-today"), + ); + } + + #[test] + fn build_subtask_activity_ignores_unsolved_entries() { + let day_start = Utc.with_ymd_and_hms(2025, 11, 5, 0, 0, 0).unwrap(); + let day_end = day_start + chrono::Duration::days(1); + + let rows = vec![row( + None, + "matching", + "task-pending", + "sub-pending", + Some("Pending Match"), + )]; + + assert!( + build_subtask_activity(&rows, &["matching"], false, day_start, day_end).is_none(), + "pending subtasks should not trigger detection", + ); + } +} + +fn derive_quizzes_from( + course_id: Option<&str>, + skill_ids: &[String], + subtask_type: &str, +) -> String { + if course_id.is_some() { + return "course".to_owned(); + } + if !skill_ids.is_empty() { + return "skill".to_owned(); + } + if subtask_type.contains("matching") { + return "course".to_owned(); + } + "quiz".to_owned() +} + +#[derive(Debug, Deserialize)] +struct RecommendationCourse { + id: String, + #[serde(default)] + title: Option, + #[serde(default)] + image: Option, +} + +#[derive(Debug, Deserialize)] +struct RecommendationSection { + id: String, + #[serde(default)] + title: Option, +} + +#[derive(Debug, Deserialize)] +struct RecommendationLecture { + id: String, + #[serde(default)] + title: Option, +} + +#[derive(Debug, Deserialize)] +struct RecommendationTask { + id: String, + subtask_id: String, + subtask_type: String, +} + +#[derive(Debug, Deserialize)] +struct NextLectureRecommendation { + course: RecommendationCourse, + section: RecommendationSection, + lecture: RecommendationLecture, +} + +#[derive(Debug, Deserialize)] +struct NextTaskRecommendation { + course: RecommendationCourse, + section: RecommendationSection, + lecture: RecommendationLecture, + task: RecommendationTask, +} + +#[derive(Debug, Deserialize)] +struct NextLabRecommendation { + course: RecommendationCourse, + section: RecommendationSection, + lecture: RecommendationLecture, + task: RecommendationTask, +} + +#[derive(Debug, Clone)] +pub struct DailyRewardActivityServiceImpl { + skills: Option, + challenges: Option, + skills_recommendations: Option, +} + +#[derive(Debug, Clone)] +struct ActivityPool { + pool: Pool>, +} + +type PgConnection<'a> = PooledConnection<'a, PostgresConnectionManager>; + +#[derive(Debug, Default)] +struct LectureMetadata { + course_title: Option, + section_id: Option, + section_title: Option, + lecture_title: Option, +} + +#[derive(Debug)] +struct SubtaskSampleInput { + task_id: String, + subtask_id: String, + subtask_type: String, + challenge_title: Option, + category_id: Option, + skill_ids: Vec, + course_id: Option, + section_id: Option, + lecture_id: Option, +} + +#[derive(Debug, Clone)] +struct SubtaskRow { + solved_timestamp: Option>, + task_id: String, + subtask_id: String, + subtask_type: String, + challenge_title: Option, + category_id: Option, + skill_ids: Vec, + course_id: Option, + section_id: Option, + lecture_id: Option, +} + +#[derive(Debug, Default)] +struct ChallengeMetadata { + challenge_title: Option, + category_id: Option, + skill_ids: Vec, + course_id: Option, + section_id: Option, + lecture_id: Option, +} + +impl ActivityPool { + async fn new(config: &PostgresActivityConfig) -> Result { + let manager = PostgresConnectionManager::new(config.dsn.parse()?, NoTls); + let min_idle = (config.min_connections > 0).then_some(config.min_connections); + let pool = Pool::builder() + .max_size(config.max_connections) + .min_idle(min_idle) + .connection_timeout(config.acquire_timeout) + .idle_timeout(config.idle_timeout) + .max_lifetime(config.max_lifetime) + .build(manager) + .await?; + + Ok(Self { pool }) + } + + async fn connection(&self) -> Result> { + self.pool.get().await.map_err(Into::into) + } +} + +async fn detect_lecture( + conn: &PgConnection<'_>, + user_id: UserId, + day_start: DateTime, + day_end: DateTime, +) -> Result> { + let day_start_naive = day_start.naive_utc(); + let day_end_naive = day_end.naive_utc(); + + let first_row = conn + .query_opt( + "select course_id, lecture_id, completed \ + from skills_lecture_progress \ + where user_id::uuid = $1 \ + and completed >= $2 \ + and completed < $3 \ + order by completed asc \ + limit 1", + &[&*user_id, &day_start_naive, &day_end_naive], + ) + .await?; + + let Some(first_row) = first_row else { + return Ok(None); + }; + + let course_id: String = first_row.get("course_id"); + let lecture_id: String = first_row.get("lecture_id"); + + let first_completed = + read_required_timestamp(&PgTimestampRow { row: &first_row }, "completed")?; + let mut last_completed = first_completed; + let maybe_last = conn + .query_opt( + "select max(completed) as completed \ + from skills_lecture_progress \ + where user_id::uuid = $1 \ + and completed >= $2 \ + and completed < $3", + &[&*user_id, &day_start_naive, &day_end_naive], + ) + .await? + .map(|row| read_optional_timestamp(&PgTimestampRow { row: &row }, "completed")) + .transpose()? + .flatten(); + + if let Some(value) = maybe_last { + last_completed = value; + } + + let metadata = fetch_lecture_metadata(conn, &course_id, &lecture_id).await?; + let sample = build_lecture_sample(course_id, lecture_id, metadata); + + Ok(Some(DailyRewardActivity { + first_detected_at: first_completed, + last_detected_at: last_completed, + activity_sample: Some(sample), + })) +} + +async fn detect_subtask( + conn: &PgConnection<'_>, + user_id: UserId, + day_start: DateTime, + day_end: DateTime, + allowed_types: &[&str], + is_lab: bool, +) -> Result> { + if allowed_types.is_empty() { + return Ok(None); + } + + let allowed: Vec<&str> = allowed_types.to_vec(); + let day_start_naive = day_start.naive_utc(); + let day_end_naive = day_end.naive_utc(); + + let rows = conn + .query( + "select \ + cs.task_id, \ + cs.ty::text as subtask_type, \ + cus.subtask_id, \ + cus.solved_timestamp, \ + cc.title as challenge_title, \ + cc.category_id, \ + cc.skill_ids, \ + cct.course_id::text as course_id, \ + cct.section_id::text as section_id, \ + cct.lecture_id::text as lecture_id \ + from challenges_user_subtasks cus \ + join challenges_subtasks cs on cs.id = cus.subtask_id \ + left join challenges_challenges cc on cc.task_id = cs.task_id \ + left join challenges_course_tasks cct on cct.task_id = cs.task_id \ + where cus.user_id = $1::uuid \ + and cus.solved_timestamp >= $2 \ + and cus.solved_timestamp < $3 \ + and cs.enabled is true \ + and cs.retired is false \ + and cs.ty::text = any($4::text[]) \ + order by cus.solved_timestamp asc", + &[&*user_id, &day_start_naive, &day_end_naive, &allowed], + ) + .await?; + + let mut subtasks = Vec::with_capacity(rows.len()); + + for row in rows { + let solved_timestamp = + read_optional_timestamp(&PgTimestampRow { row: &row }, "solved_timestamp")?; + let task_id: Uuid = row.get("task_id"); + let subtask_id: Uuid = row.get("subtask_id"); + let challenge_title = row + .try_get::<_, Option>("challenge_title") + .unwrap_or(None) + .and_then(normalize_db_string); + let category_id = row + .try_get::<_, Option>("category_id") + .unwrap_or(None); + let skill_ids: Vec = match row.try_get::<_, Vec>("skill_ids") { + Ok(ids) => ids, + Err(_) => row + .try_get::<_, Vec>("skill_ids") + .map(|ids| ids.into_iter().map(|id| id.to_string()).collect()) + .unwrap_or_default(), + }; + let course_id = row + .try_get::<_, Option>("course_id") + .unwrap_or(None) + .and_then(normalize_db_string); + let section_id = row + .try_get::<_, Option>("section_id") + .unwrap_or(None) + .and_then(normalize_db_string); + let lecture_id = row + .try_get::<_, Option>("lecture_id") + .unwrap_or(None) + .and_then(normalize_db_string); + + subtasks.push(SubtaskRow { + solved_timestamp, + task_id: task_id.to_string(), + subtask_id: subtask_id.to_string(), + subtask_type: row.get("subtask_type"), + challenge_title, + category_id, + skill_ids, + course_id, + section_id, + lecture_id, + }); + } + + Ok(build_subtask_activity( + &subtasks, + allowed_types, + is_lab, + day_start, + day_end, + )) +} + +fn build_subtask_activity( + rows: &[SubtaskRow], + allowed_types: &[&str], + is_lab: bool, + day_start: DateTime, + day_end: DateTime, +) -> Option { + let mut first_detected_at: Option> = None; + let mut last_detected_at: Option> = None; + let mut sample = None; + + for row in rows { + let Some(solved_timestamp) = row.solved_timestamp else { + continue; + }; + + if solved_timestamp < day_start || solved_timestamp >= day_end { + continue; + } + + if !allowed_types + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(row.subtask_type.as_str())) + { + continue; + } + + if first_detected_at.is_none() { + first_detected_at = Some(solved_timestamp); + sample = Some(build_subtask_sample( + SubtaskSampleInput { + task_id: row.task_id.clone(), + subtask_id: row.subtask_id.clone(), + subtask_type: row.subtask_type.clone(), + challenge_title: row.challenge_title.clone(), + category_id: row.category_id, + skill_ids: row.skill_ids.clone(), + course_id: row.course_id.clone(), + section_id: row.section_id.clone(), + lecture_id: row.lecture_id.clone(), + }, + is_lab, + )); + } + + last_detected_at = Some(match last_detected_at { + Some(existing) if existing >= solved_timestamp => existing, + _ => solved_timestamp, + }); + } + + let first_detected_at = first_detected_at?; + let last_detected_at = last_detected_at.unwrap_or(first_detected_at); + + Some(DailyRewardActivity { + first_detected_at, + last_detected_at, + activity_sample: sample, + }) +} + +impl DailyRewardActivityServiceImpl { + pub async fn new( + skills: Option, + challenges: Option, + skills_recommendations: Option, + ) -> Result { + let skills_pool = match skills { + Some(ref cfg) => Some( + ActivityPool::new(cfg) + .await + .context("Failed to initialise skills activity pool")?, + ), + None => None, + }; + + let challenges_pool = match challenges { + Some(ref cfg) => Some( + ActivityPool::new(cfg) + .await + .context("Failed to initialise challenges activity pool")?, + ), + None => None, + }; + + let recommendations_client = match skills_recommendations { + Some(ref cfg) => Some( + SkillsRecommendationClient::new(cfg) + .context("Failed to initialise skills recommendations client")?, + ), + None => None, + }; + + Ok(Self { + skills: skills_pool, + challenges: challenges_pool, + skills_recommendations: recommendations_client, + }) + } +} + +impl DailyRewardActivityService for DailyRewardActivityServiceImpl { + async fn detect( + &self, + token: Option<&AccessToken>, + user_id: UserId, + day_start: DateTime, + day_end: DateTime, + ) -> Result { + let mut snapshot = DailyRewardActivitySnapshot::default(); + + if let Some(skills) = &self.skills { + match skills.connection().await { + Ok(conn) => match detect_lecture(&conn, user_id, day_start, day_end).await { + Ok(Some(activity)) => snapshot.lecture.detected = Some(activity), + Ok(None) => {} + Err(err) => { + warn!(error = ?err, "Failed to detect lecture completion"); + snapshot.lecture.unavailable_reason = + Some(DailyRewardUnavailableReason::Unknown); + } + }, + Err(err) => { + warn!(error = %err, "Failed to acquire skills connection"); + snapshot.lecture.unavailable_reason = + Some(DailyRewardUnavailableReason::Unknown); + } + } + } + + if let Some(challenges) = &self.challenges { + match challenges.connection().await { + Ok(conn) => { + match detect_subtask( + &conn, + user_id, + day_start, + day_end, + &["matching", "multiple_choice_question", "question"], + false, + ) + .await + { + Ok(Some(activity)) => snapshot.practice.detected = Some(activity), + Ok(None) => {} + Err(err) => { + warn!(error = ?err, "Failed to detect practice completion"); + snapshot.practice.unavailable_reason = + Some(DailyRewardUnavailableReason::Unknown); + } + } + + match detect_subtask( + &conn, + user_id, + day_start, + day_end, + &["coding_challenge"], + true, + ) + .await + { + Ok(Some(activity)) => snapshot.lab.detected = Some(activity), + Ok(None) => {} + Err(err) => { + warn!(error = ?err, "Failed to detect lab completion"); + snapshot.lab.unavailable_reason = + Some(DailyRewardUnavailableReason::Unknown); + } + } + } + Err(err) => { + warn!(error = %err, "Failed to acquire challenges connection"); + snapshot.practice.unavailable_reason = + Some(DailyRewardUnavailableReason::Unknown); + snapshot.lab.unavailable_reason = Some(DailyRewardUnavailableReason::Unknown); + } + } + } + + if let (Some(token), Some(client)) = (token, &self.skills_recommendations) { + match client.ext_lecture(token).await { + Ok(Some(recommendation)) => { + let sample = self.build_lecture_recommendation_sample(&recommendation); + Self::apply_recommendation(&mut snapshot.lecture, Some(sample)); + } + Ok(None) => Self::apply_recommendation(&mut snapshot.lecture, None), + Err(err) => { + warn!(error = %err, "Failed to fetch lecture recommendation"); + Self::mark_recommendation_error(&mut snapshot.lecture); + } + } + + match client.ext_task(token).await { + Ok(Some(recommendation)) => match self + .build_task_recommendation_sample(&recommendation, false) + .await + { + Ok(sample) => Self::apply_recommendation(&mut snapshot.practice, Some(sample)), + Err(err) => { + warn!(error = %err, "Failed to enrich practice recommendation"); + Self::mark_recommendation_error(&mut snapshot.practice); + } + }, + Ok(None) => Self::apply_recommendation(&mut snapshot.practice, None), + Err(err) => { + warn!(error = %err, "Failed to fetch practice recommendation"); + Self::mark_recommendation_error(&mut snapshot.practice); + } + } + + match client.ext_lab(token).await { + Ok(Some(recommendation)) => { + match self.build_lab_recommendation_sample(&recommendation).await { + Ok(sample) => Self::apply_recommendation(&mut snapshot.lab, Some(sample)), + Err(err) => { + warn!(error = %err, "Failed to enrich lab recommendation"); + Self::mark_recommendation_error(&mut snapshot.lab); + } + } + } + Ok(None) => Self::apply_recommendation(&mut snapshot.lab, None), + Err(err) => { + warn!(error = %err, "Failed to fetch lab recommendation"); + Self::mark_recommendation_error(&mut snapshot.lab); + } + } + } + + Ok(snapshot) + } +} + +impl DailyRewardActivityServiceImpl { + fn build_lecture_recommendation_sample( + &self, + recommendation: &NextLectureRecommendation, + ) -> Value { + let metadata = LectureMetadata { + course_title: recommendation.course.title.clone(), + section_id: Some(recommendation.section.id.clone()), + section_title: recommendation.section.title.clone(), + lecture_title: recommendation.lecture.title.clone(), + }; + + let mut sample = build_lecture_sample( + recommendation.course.id.clone(), + recommendation.lecture.id.clone(), + Some(metadata), + ); + + if let Value::Object(ref mut map) = sample + && let Some(image) = &recommendation.course.image + { + map.insert("course_image".into(), Value::String(image.clone())); + duplicate_string_field(map, "course_image", "courseImage"); + } + + sample + } + + async fn build_task_recommendation_sample( + &self, + recommendation: &NextTaskRecommendation, + is_lab: bool, + ) -> Result { + self.build_subtask_recommendation_sample( + &recommendation.task.id, + &recommendation.task.subtask_id, + &recommendation.task.subtask_type, + ( + &recommendation.course, + &recommendation.section, + &recommendation.lecture, + ), + is_lab, + ) + .await + } + + async fn build_lab_recommendation_sample( + &self, + recommendation: &NextLabRecommendation, + ) -> Result { + self.build_subtask_recommendation_sample( + &recommendation.task.id, + &recommendation.task.subtask_id, + &recommendation.task.subtask_type, + ( + &recommendation.course, + &recommendation.section, + &recommendation.lecture, + ), + true, + ) + .await + } + + async fn build_subtask_recommendation_sample( + &self, + task_id: &str, + subtask_id: &str, + subtask_type: &str, + context: ( + &RecommendationCourse, + &RecommendationSection, + &RecommendationLecture, + ), + is_lab: bool, + ) -> Result { + let (course, section, lecture) = context; + let metadata = self.fetch_challenge_metadata(task_id).await?; + let ChallengeMetadata { + challenge_title, + category_id, + skill_ids, + course_id, + section_id, + lecture_id, + } = metadata.unwrap_or_default(); + + let challenge_title = challenge_title.or_else(|| lecture.title.clone()); + let course_id = course_id.or_else(|| Some(course.id.clone())); + let section_id = section_id.or_else(|| Some(section.id.clone())); + let lecture_id = lecture_id.or_else(|| Some(lecture.id.clone())); + + let mut sample = build_subtask_sample( + SubtaskSampleInput { + task_id: task_id.to_owned(), + subtask_id: subtask_id.to_owned(), + subtask_type: subtask_type.to_owned(), + challenge_title, + category_id, + skill_ids, + course_id, + section_id, + lecture_id, + }, + is_lab, + ); + + if let Value::Object(ref mut map) = sample { + if let Some(image) = &course.image { + map.insert("course_image".into(), Value::String(image.clone())); + duplicate_string_field(map, "course_image", "courseImage"); + } + if let Some(title) = &course.title { + map.insert("course_title".into(), Value::String(title.clone())); + duplicate_string_field(map, "course_title", "courseTitle"); + } + if let Some(section_title) = §ion.title { + map.insert("section_title".into(), Value::String(section_title.clone())); + duplicate_string_field(map, "section_title", "sectionTitle"); + } + if let Some(lecture_title) = &lecture.title { + map.insert("lecture_title".into(), Value::String(lecture_title.clone())); + duplicate_string_field(map, "lecture_title", "lectureTitle"); + } + } + + Ok(sample) + } + + async fn fetch_challenge_metadata(&self, task_id: &str) -> Result> { + let Some(challenges) = &self.challenges else { + return Ok(None); + }; + + let task_uuid = match Uuid::parse_str(task_id) { + Ok(uuid) => uuid, + Err(err) => { + warn!(error = %err, %task_id, "Skipping challenge metadata; invalid task_id"); + return Ok(None); + } + }; + + let conn = match challenges.connection().await { + Ok(conn) => conn, + Err(err) => { + warn!( + error = %err, + "Failed to acquire challenges connection for recommendation metadata" + ); + return Ok(None); + } + }; + + let row = conn + .query_opt( + "select \ + cc.title as challenge_title, \ + cc.category_id, \ + cc.skill_ids, \ + cct.course_id::text as course_id, \ + cct.section_id::text as section_id, \ + cct.lecture_id::text as lecture_id \ + from challenges_challenges cc \ + left join challenges_course_tasks cct on cct.task_id = cc.task_id \ + where cc.task_id = $1 \ + limit 1", + &[&task_uuid], + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let challenge_title = row + .try_get::<_, Option>("challenge_title") + .unwrap_or(None) + .and_then(normalize_db_string); + let category_id = row + .try_get::<_, Option>("category_id") + .unwrap_or(None); + let skill_ids: Vec = match row.try_get::<_, Vec>("skill_ids") { + Ok(ids) => ids, + Err(_) => row + .try_get::<_, Vec>("skill_ids") + .map(|ids| ids.into_iter().map(|id| id.to_string()).collect()) + .unwrap_or_default(), + }; + let course_id = row + .try_get::<_, Option>("course_id") + .unwrap_or(None) + .and_then(normalize_db_string); + let section_id = row + .try_get::<_, Option>("section_id") + .unwrap_or(None) + .and_then(normalize_db_string); + let lecture_id = row + .try_get::<_, Option>("lecture_id") + .unwrap_or(None) + .and_then(normalize_db_string); + + Ok(Some(ChallengeMetadata { + challenge_title, + category_id, + skill_ids, + course_id, + section_id, + lecture_id, + })) + } + + fn apply_recommendation(state: &mut DailyRewardActivityState, sample: Option) { + if state.detected.is_some() { + // The user already completed the activity; do not override with pending data. + return; + } + + match sample { + Some(sample) => { + state.pending_sample = Some(sample); + state.unavailable_reason = None; + } + None => { + if state.unavailable_reason.is_none() { + state.unavailable_reason = Some(DailyRewardUnavailableReason::NoRecommendation); + } + } + } + } + + fn mark_recommendation_error(state: &mut DailyRewardActivityState) { + if state.detected.is_some() { + return; + } + if state.unavailable_reason.is_none() { + state.unavailable_reason = Some(DailyRewardUnavailableReason::Unknown); + } + } +} diff --git a/academy_core/daily_rewards/impl/src/lib.rs b/academy_core/daily_rewards/impl/src/lib.rs new file mode 100644 index 00000000..41698757 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/lib.rs @@ -0,0 +1,13 @@ +mod activity; +mod service; + +#[cfg(test)] +mod tests; + +pub use activity::{ + ChallengesActivityConfig, DailyRewardActivityServiceImpl, SkillsActivityConfig, + SkillsRecommendationConfig, +}; +pub use service::{ + DailyRewardCoinsConfig, DailyRewardFeatureConfig, DailyRewardFeatureServiceImpl, +}; diff --git a/academy_core/daily_rewards/impl/src/service.rs b/academy_core/daily_rewards/impl/src/service.rs new file mode 100644 index 00000000..2c8f78e1 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/service.rs @@ -0,0 +1,1218 @@ +use std::{collections::HashMap, time::Duration}; + +use academy_auth_contracts::{AuthResultExt, AuthService}; +use academy_cache_contracts::CacheService; +use academy_core_coin_contracts::coin::CoinService; +use academy_core_daily_rewards_contracts::{ + DailyRewardActivityService, DailyRewardActivitySnapshot, DailyRewardActivityState, + DailyRewardClaimAllError, DailyRewardClaimAllResponse, DailyRewardClaimError, + DailyRewardClaimResponse, DailyRewardClaimSkip, DailyRewardClaimSkipReason, + DailyRewardClaimSuccess, DailyRewardFeatureService, DailyRewardGetError, + DailyRewardGetResponse, DailyRewardItem, DailyRewardStatus, DailyRewardUnavailableReason, + DailyRewardsSnapshot, +}; +use academy_di::Build; +use academy_models::{ + auth::AccessToken, + coin::TransactionDescription, + daily_rewards::{DailyRewardCategory, DailyRewardEntry}, + user::UserId, +}; +use academy_persistence_contracts::{ + Database, Transaction, + daily_rewards::{ + DailyRewardEntryUpsert, DailyRewardMarkClaimed, DailyRewardMarkClaimedError, + DailyRewardMarkReady, DailyRewardRepository, + }, +}; +use academy_shared_contracts::{id::IdService, time::TimeService}; +use academy_utils::trace_instrument; +use anyhow::{Result, anyhow}; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use tracing::{info, warn}; + +#[derive(Debug, Clone)] +pub struct DailyRewardCoinsConfig { + pub arrival: i32, + pub lecture: i32, + pub practice: i32, + pub lab: i32, +} + +impl DailyRewardCoinsConfig { + fn get(&self, category: DailyRewardCategory) -> i32 { + match category { + DailyRewardCategory::Arrival => self.arrival, + DailyRewardCategory::Lecture => self.lecture, + DailyRewardCategory::Practice => self.practice, + DailyRewardCategory::Lab => self.lab, + } + } +} + +#[derive(Debug, Clone)] +pub struct DailyRewardFeatureConfig { + pub enable: bool, + pub coins: DailyRewardCoinsConfig, + pub cache_ttl: Option, +} + +#[derive(Debug, Clone, Build)] +pub struct DailyRewardFeatureServiceImpl { + db: Db, + auth: Auth, + repo: Repo, + coin: Coin, + cache: Cache, + activity: Activity, + id: Id, + time: Time, + config: DailyRewardFeatureConfig, +} + +pub(crate) struct RefreshedRewards { + pub(crate) entries: HashMap, + pub(crate) unavailability: HashMap>, +} + +impl DailyRewardFeatureService + for DailyRewardFeatureServiceImpl +where + Db: Database, + Auth: AuthService, + Repo: DailyRewardRepository, + Coin: CoinService, + Cache: CacheService, + Activity: DailyRewardActivityService, + Id: IdService, + Time: TimeService, +{ + #[trace_instrument(skip(self))] + async fn get_today( + &self, + token: &AccessToken, + ) -> Result { + if !self.config.enable { + return Err(DailyRewardGetError::FeatureDisabled); + } + + let auth = self.auth.authenticate(token).await.map_auth_err()?; + let user_id = auth.user_id; + + let now = self.time.now(); + let date = now.date_naive(); + let cache_ttl = self.config.cache_ttl; + let cache_key = cache_ttl.map(|_| cache_key(user_id, date)); + + let mut cached_snapshot = None; + + if let Some(cache_key) = cache_key.as_ref() { + match self.cache.get::(cache_key).await { + Ok(Some(snapshot)) => { + cached_snapshot = Some(snapshot); + } + Ok(None) => {} + Err(err) => { + warn!( + error = %err, + user_id = ?user_id, + date = %date, + "Failed to retrieve daily rewards snapshot from cache" + ); + } + } + } + + let day_start = start_of_day_utc(date); + let day_end = day_start + chrono::Duration::days(1); + + let mut txn = self + .db + .begin_transaction() + .await + .map_err(DailyRewardGetError::Other)?; + + let refreshed = match self + .refresh_entries(&mut txn, Some(token), user_id, date, day_start, day_end) + .await + { + Ok(refreshed) => refreshed, + Err(err) => { + if let Some(snapshot) = cached_snapshot { + warn!( + error = %err, + user_id = ?user_id, + date = %date, + "Failed to refresh daily rewards; returning cached snapshot" + ); + if let Err(rollback_err) = txn.rollback().await { + warn!( + error = %rollback_err, + user_id = ?user_id, + date = %date, + "Failed to rollback transaction after refresh failure" + ); + } + self.emit_view_event(user_id, &snapshot); + return Ok(DailyRewardGetResponse { snapshot }); + } + return Err(DailyRewardGetError::Other(err)); + } + }; + + txn.commit().await.map_err(DailyRewardGetError::Other)?; + + let snapshot = build_snapshot(date, refreshed); + + if let (Some(ttl), Some(cache_key)) = (cache_ttl, cache_key.as_ref()) + && let Err(err) = self.cache.set(cache_key, &snapshot, Some(ttl)).await + { + warn!( + error = %err, + user_id = ?user_id, + date = %date, + "Failed to store daily rewards snapshot in cache" + ); + } + + self.emit_view_event(user_id, &snapshot); + + Ok(DailyRewardGetResponse { snapshot }) + } + + #[trace_instrument(skip(self))] + async fn claim( + &self, + token: &AccessToken, + category: DailyRewardCategory, + ) -> Result { + if !self.config.enable { + return Err(DailyRewardClaimError::FeatureDisabled); + } + + let auth = self.auth.authenticate(token).await.map_auth_err()?; + let user_id = auth.user_id; + + let now = self.time.now(); + let date = now.date_naive(); + let day_start = start_of_day_utc(date); + let day_end = day_start + chrono::Duration::days(1); + let coins = self.config.coins.get(category); + + let mut txn = self + .db + .begin_transaction() + .await + .map_err(DailyRewardClaimError::Other)?; + + let refreshed = self + .refresh_entries(&mut txn, Some(token), user_id, date, day_start, day_end) + .await + .map_err(DailyRewardClaimError::Other)?; + + let entry = refreshed.entries.get(&category).ok_or_else(|| { + DailyRewardClaimError::Other(anyhow!("Reward entry missing for category {category}")) + })?; + + if entry.claimed_at.is_some() { + return Err(DailyRewardClaimError::AlreadyClaimed); + } + + if entry.claimable_since.is_none() { + if refreshed + .unavailability + .get(&category) + .and_then(|r| *r) + .is_some() + { + return Err(DailyRewardClaimError::Unavailable); + } + return Err(DailyRewardClaimError::NotReady); + } + + let mark_params = DailyRewardMarkClaimed { + user_id, + date_utc: date, + category, + claimed_at: now, + }; + + let updated_entry = self + .repo + .mark_claimed(&mut txn, mark_params) + .await + .map_err(|err| map_claim_error(err, category))?; + + let description = TransactionDescription::try_from(format!("Daily reward - {}", category)) + .map(Some) + .map_err(|err| DailyRewardClaimError::Other(err.into()))?; + + self.coin + .add_coins(&mut txn, user_id, coins as i64, false, description, false) + .await + .map_err(|err| DailyRewardClaimError::Other(err.into()))?; + + let claimed_entry = updated_entry.clone(); + let claimed_at = claimed_entry.claimed_at.unwrap_or(now); + + txn.commit().await.map_err(DailyRewardClaimError::Other)?; + + self.invalidate_cache(user_id, date).await; + + self.emit_claim_event(user_id, category, coins, claimed_at, &claimed_entry); + + Ok(DailyRewardClaimResponse { + success: DailyRewardClaimSuccess { + category, + coins, + claimed_at, + }, + }) + } + + #[trace_instrument(skip(self))] + async fn claim_all( + &self, + token: &AccessToken, + ) -> Result { + if !self.config.enable { + return Err(DailyRewardClaimAllError::FeatureDisabled); + } + + let auth = self.auth.authenticate(token).await.map_auth_err()?; + let user_id = auth.user_id; + + let now = self.time.now(); + let date = now.date_naive(); + let day_start = start_of_day_utc(date); + let day_end = day_start + chrono::Duration::days(1); + + let mut txn = self + .db + .begin_transaction() + .await + .map_err(DailyRewardClaimAllError::Other)?; + + let refreshed = self + .refresh_entries(&mut txn, Some(token), user_id, date, day_start, day_end) + .await + .map_err(DailyRewardClaimAllError::Other)?; + + let mut claimed = Vec::new(); + let mut skipped = Vec::new(); + let mut claimed_entries = Vec::new(); + + for category in DailyRewardCategory::ALL { + let coins = self.config.coins.get(category); + let entry = match refreshed.entries.get(&category) { + Some(entry) => entry, + None => { + skipped.push(DailyRewardClaimSkip { + category, + reason: DailyRewardClaimSkipReason::Error, + }); + continue; + } + }; + + if entry.claimed_at.is_some() { + skipped.push(DailyRewardClaimSkip { + category, + reason: DailyRewardClaimSkipReason::AlreadyClaimed, + }); + continue; + } + + if entry.claimable_since.is_none() { + let reason = refreshed + .unavailability + .get(&category) + .and_then(|r| *r) + .map(|_| DailyRewardClaimSkipReason::Unavailable) + .unwrap_or(DailyRewardClaimSkipReason::Pending); + + skipped.push(DailyRewardClaimSkip { category, reason }); + continue; + } + + let mark_params = DailyRewardMarkClaimed { + user_id, + date_utc: date, + category, + claimed_at: now, + }; + + match self.repo.mark_claimed(&mut txn, mark_params).await { + Ok(updated_entry) => { + let description = + TransactionDescription::try_from(format!("Daily reward - {}", category)) + .map(Some) + .map_err(|err| DailyRewardClaimAllError::Other(err.into()))?; + + if let Err(err) = self + .coin + .add_coins(&mut txn, user_id, coins as i64, false, description, false) + .await + { + return Err(DailyRewardClaimAllError::Other(err.into())); + } + + let claimed_at = updated_entry.claimed_at.unwrap_or(now); + claimed.push(DailyRewardClaimSuccess { + category, + coins, + claimed_at, + }); + claimed_entries.push((category, coins, claimed_at, updated_entry)); + } + Err(DailyRewardMarkClaimedError::NotReady) => { + skipped.push(DailyRewardClaimSkip { + category, + reason: DailyRewardClaimSkipReason::Pending, + }); + } + Err(DailyRewardMarkClaimedError::AlreadyClaimed) => { + skipped.push(DailyRewardClaimSkip { + category, + reason: DailyRewardClaimSkipReason::AlreadyClaimed, + }); + } + Err(DailyRewardMarkClaimedError::NotFound) => { + skipped.push(DailyRewardClaimSkip { + category, + reason: DailyRewardClaimSkipReason::Error, + }); + } + Err(DailyRewardMarkClaimedError::Other(err)) => { + return Err(DailyRewardClaimAllError::Other(err)); + } + } + } + + txn.commit() + .await + .map_err(DailyRewardClaimAllError::Other)?; + + if !claimed_entries.is_empty() { + self.invalidate_cache(user_id, date).await; + } + + for (category, coins, claimed_at, entry) in &claimed_entries { + self.emit_claim_event(user_id, *category, *coins, *claimed_at, entry); + } + self.emit_claim_all_event(user_id, &claimed, &skipped); + + Ok(DailyRewardClaimAllResponse { claimed, skipped }) + } +} + +impl + DailyRewardFeatureServiceImpl +where + Db: Database, + Repo: DailyRewardRepository, + Cache: CacheService, + Activity: DailyRewardActivityService, + Id: IdService, + Time: TimeService, +{ + fn emit_view_event(&self, user_id: UserId, snapshot: &DailyRewardsSnapshot) { + let ready_categories = snapshot + .rewards + .iter() + .filter(|reward| matches!(reward.status, DailyRewardStatus::Ready)) + .map(|reward| reward.category.as_str()) + .collect::>(); + let unavailable_categories = snapshot + .rewards + .iter() + .filter(|reward| matches!(reward.status, DailyRewardStatus::Unavailable)) + .map(|reward| reward.category.as_str()) + .collect::>(); + + info!( + event = "daily_reward.viewed", + user_id = ?user_id, + date = %snapshot.date_utc, + available_coins = snapshot.claim_totals.available_coins, + claimed_today = snapshot.claim_totals.claimed_today, + ready_categories = ?ready_categories, + unavailable_categories = ?unavailable_categories, + feature_enabled = snapshot.feature_enabled, + ); + } + + fn emit_claim_event( + &self, + user_id: UserId, + category: DailyRewardCategory, + coins: i32, + claimed_at: DateTime, + entry: &DailyRewardEntry, + ) { + info!( + event = "daily_reward.claimed", + user_id = ?user_id, + category = %category, + coins, + claimed_at = %claimed_at, + date = %entry.date_utc, + first_detected_at = ?entry.first_detected_at, + last_detected_at = ?entry.last_detected_at, + claimable_since = ?entry.claimable_since, + activity_sample = ?entry.activity_sample, + ); + } + + fn emit_claim_all_event( + &self, + user_id: UserId, + claimed: &[DailyRewardClaimSuccess], + skipped: &[DailyRewardClaimSkip], + ) { + let claimed_categories = claimed + .iter() + .map(|success| success.category.as_str()) + .collect::>(); + let skipped_details = skipped + .iter() + .map(|skip| (skip.category.as_str(), skip.reason)) + .collect::>(); + let total_claimed_coins: i32 = claimed.iter().map(|success| success.coins).sum(); + + info!( + event = "daily_reward.claim_all", + user_id = ?user_id, + total_claimed_coins, + claimed_categories = ?claimed_categories, + skipped = ?skipped_details, + ); + } + + fn emit_category_ready_event( + &self, + user_id: UserId, + category: DailyRewardCategory, + entry: &DailyRewardEntry, + ) { + info!( + event = "daily_reward.category_ready", + user_id = ?user_id, + category = %category, + coins = entry.coins, + date = %entry.date_utc, + first_detected_at = ?entry.first_detected_at, + last_detected_at = ?entry.last_detected_at, + claimable_since = ?entry.claimable_since, + activity_sample = ?entry.activity_sample, + ); + } + + async fn invalidate_cache(&self, user_id: UserId, date: NaiveDate) { + if self.config.cache_ttl.is_none() { + return; + } + + let cache_key = cache_key(user_id, date); + if let Err(err) = self.cache.remove(&cache_key).await { + warn!( + error = %err, + user_id = ?user_id, + date = %date, + "Failed to remove daily rewards snapshot from cache" + ); + } + } + + pub async fn rebuild_snapshot( + &self, + user_id: UserId, + date: NaiveDate, + ) -> Result { + let cache_ttl = self.config.cache_ttl; + let cache_key = cache_ttl.map(|_| cache_key(user_id, date)); + + let day_start = start_of_day_utc(date); + let day_end = day_start + chrono::Duration::days(1); + + let mut txn = self.db.begin_transaction().await?; + let refreshed = self + .refresh_entries(&mut txn, None, user_id, date, day_start, day_end) + .await?; + txn.commit().await?; + + self.invalidate_cache(user_id, date).await; + + let snapshot = build_snapshot(date, refreshed); + + if let (Some(ttl), Some(cache_key)) = (cache_ttl, cache_key.as_ref()) { + match self.cache.set(cache_key, &snapshot, Some(ttl)).await { + Ok(()) => {} + Err(err) => { + warn!( + error = %err, + user_id = ?user_id, + date = %date, + "Failed to store rebuilt daily rewards snapshot in cache" + ); + } + } + } + + Ok(snapshot) + } +} + +#[cfg(test)] +impl + DailyRewardFeatureServiceImpl +where + Db: Database, + Repo: DailyRewardRepository, + Cache: CacheService, + Activity: DailyRewardActivityService, + Id: IdService, + Time: TimeService, +{ + #[allow( + clippy::too_many_arguments, + reason = "Test helper wiring all dependencies explicitly for clarity" + )] + pub(crate) fn new_for_tests( + db: Db, + auth: Auth, + repo: Repo, + coin: Coin, + cache: Cache, + activity: Activity, + id: Id, + time: Time, + config: DailyRewardFeatureConfig, + ) -> Self { + Self { + db, + auth, + repo, + coin, + cache, + activity, + id, + time, + config, + } + } +} + +fn start_of_day_utc(date: NaiveDate) -> DateTime { + date.and_time(NaiveTime::MIN).and_utc() +} + +fn cache_key(user_id: UserId, date: NaiveDate) -> String { + format!("daily_rewards:{}:{}", user_id.into_inner(), date) +} + +fn map_claim_error( + err: DailyRewardMarkClaimedError, + category: DailyRewardCategory, +) -> DailyRewardClaimError { + match err { + DailyRewardMarkClaimedError::NotReady => DailyRewardClaimError::NotReady, + DailyRewardMarkClaimedError::AlreadyClaimed => DailyRewardClaimError::AlreadyClaimed, + DailyRewardMarkClaimedError::NotFound => { + DailyRewardClaimError::Other(anyhow!("Reward entry not found for {category}")) + } + DailyRewardMarkClaimedError::Other(err) => DailyRewardClaimError::Other(err), + } +} + +pub(crate) fn build_snapshot(date: NaiveDate, refreshed: RefreshedRewards) -> DailyRewardsSnapshot { + let mut rewards = Vec::new(); + let mut available_total = 0; + let mut claimed_total = 0; + + for category in DailyRewardCategory::ALL { + if let Some(entry) = refreshed.entries.get(&category) { + let unavailable_reason = refreshed.unavailability.get(&category).copied().flatten(); + let status = determine_status(entry, unavailable_reason); + + if entry.claimable_since.is_some() && entry.claimed_at.is_none() { + available_total += entry.coins; + } + + if entry + .claimed_at + .is_some_and(|claimed_at| claimed_at.date_naive() == date) + { + claimed_total += entry.coins; + } + + rewards.push(DailyRewardItem { + category, + coins: entry.coins, + status, + claimable_since: entry.claimable_since, + last_detected_at: entry.last_detected_at, + claimed_at: entry.claimed_at, + activity_sample: entry.activity_sample.clone(), + unavailable_reason, + }); + } + } + + DailyRewardsSnapshot { + date_utc: date, + feature_enabled: true, + rewards, + claim_totals: academy_core_daily_rewards_contracts::DailyRewardClaimTotals { + available_coins: available_total, + claimed_today: claimed_total, + }, + } +} + +fn determine_status( + entry: &DailyRewardEntry, + unavailable: Option, +) -> DailyRewardStatus { + if entry.claimed_at.is_some() { + DailyRewardStatus::Claimed + } else if entry.claimable_since.is_some() { + DailyRewardStatus::Ready + } else if unavailable.is_some() { + DailyRewardStatus::Unavailable + } else { + DailyRewardStatus::Pending + } +} + +impl + DailyRewardFeatureServiceImpl +where + Db: Database, + Repo: DailyRewardRepository, + Cache: CacheService, + Activity: DailyRewardActivityService, + Id: IdService, + Time: TimeService, +{ + async fn refresh_entries( + &self, + txn: &mut Db::Transaction, + token: Option<&AccessToken>, + user_id: UserId, + date: NaiveDate, + day_start: DateTime, + day_end: DateTime, + ) -> Result { + let mut entries = self.repo.list_by_user_and_date(txn, user_id, date).await?; + + let mut map: HashMap = entries + .drain(..) + .map(|entry| (entry.category, entry)) + .collect(); + + for category in DailyRewardCategory::ALL { + let coins = self.config.coins.get(category); + let maybe_entry = map.get(&category).cloned(); + let entry = match maybe_entry { + Some(entry) if entry.claimed_at.is_some() => entry, + Some(entry) if entry.coins == coins => entry, + Some(entry) => { + let params = DailyRewardEntryUpsert { + id: entry.id, + user_id, + date_utc: date, + category, + coins, + }; + self.repo.upsert_entry(txn, params).await? + } + None => { + let new_id: uuid::Uuid = self.id.generate(); + let params = DailyRewardEntryUpsert { + id: new_id, + user_id, + date_utc: date, + category, + coins, + }; + self.repo.upsert_entry(txn, params).await? + } + }; + map.insert(category, entry); + } + + if let Some(entry) = map.get(&DailyRewardCategory::Arrival).cloned() { + let was_ready = entry.claimable_since.is_some(); + if entry.claimable_since.is_none() && entry.claimed_at.is_none() { + let now = self.time.now(); + let params = DailyRewardMarkReady { + user_id, + date_utc: date, + category: DailyRewardCategory::Arrival, + first_detected_at: Some(now), + last_detected_at: Some(now), + claimable_since: Some(now), + activity_sample: None, + }; + let updated = self.repo.mark_ready(txn, params).await?; + if !was_ready && updated.claimable_since.is_some() { + self.emit_category_ready_event(user_id, DailyRewardCategory::Arrival, &updated); + } + map.insert(DailyRewardCategory::Arrival, updated); + } + } + + let activity = self + .activity + .detect(token, user_id, day_start, day_end) + .await + .unwrap_or_else(|err| { + warn!(error = %err, "Failed to fetch activity data"); + DailyRewardActivitySnapshot::default() + }); + + if let (true, Some(entry)) = ( + apply_activity( + txn, + user_id, + date, + DailyRewardCategory::Lecture, + &activity.lecture, + &self.repo, + &mut map, + ) + .await?, + map.get(&DailyRewardCategory::Lecture), + ) { + self.emit_category_ready_event(user_id, DailyRewardCategory::Lecture, entry); + } + if let (true, Some(entry)) = ( + apply_activity( + txn, + user_id, + date, + DailyRewardCategory::Practice, + &activity.practice, + &self.repo, + &mut map, + ) + .await?, + map.get(&DailyRewardCategory::Practice), + ) { + self.emit_category_ready_event(user_id, DailyRewardCategory::Practice, entry); + } + if let (true, Some(entry)) = ( + apply_activity( + txn, + user_id, + date, + DailyRewardCategory::Lab, + &activity.lab, + &self.repo, + &mut map, + ) + .await?, + map.get(&DailyRewardCategory::Lab), + ) { + self.emit_category_ready_event(user_id, DailyRewardCategory::Lab, entry); + } + + apply_pending_sample( + txn, + user_id, + date, + DailyRewardCategory::Lecture, + &activity.lecture, + &self.repo, + &mut map, + ) + .await?; + apply_pending_sample( + txn, + user_id, + date, + DailyRewardCategory::Practice, + &activity.practice, + &self.repo, + &mut map, + ) + .await?; + apply_pending_sample( + txn, + user_id, + date, + DailyRewardCategory::Lab, + &activity.lab, + &self.repo, + &mut map, + ) + .await?; + + let mut unavailability = HashMap::new(); + unavailability.insert( + DailyRewardCategory::Lecture, + activity.lecture.unavailable_reason, + ); + unavailability.insert( + DailyRewardCategory::Practice, + activity.practice.unavailable_reason, + ); + unavailability.insert(DailyRewardCategory::Lab, activity.lab.unavailable_reason); + unavailability.insert(DailyRewardCategory::Arrival, None); + + Ok(RefreshedRewards { + entries: map, + unavailability, + }) + } +} + +async fn apply_activity( + txn: &mut Txn, + user_id: UserId, + date: NaiveDate, + category: DailyRewardCategory, + state: &DailyRewardActivityState, + repo: &Repo, + map: &mut HashMap, +) -> Result +where + Repo: DailyRewardRepository, + Txn: Transaction, +{ + let Some(activity) = &state.detected else { + return Ok(false); + }; + + let was_ready = map + .get(&category) + .and_then(|entry| entry.claimable_since) + .is_some(); + + let params = DailyRewardMarkReady { + user_id, + date_utc: date, + category, + first_detected_at: Some(activity.first_detected_at), + last_detected_at: Some(activity.last_detected_at), + claimable_since: Some(activity.first_detected_at), + activity_sample: activity.activity_sample.clone(), + }; + + let updated = repo.mark_ready(txn, params).await?; + let is_ready = updated.claimable_since.is_some(); + map.insert(category, updated); + Ok(!was_ready && is_ready) +} + +async fn apply_pending_sample( + txn: &mut Txn, + user_id: UserId, + date: NaiveDate, + category: DailyRewardCategory, + state: &DailyRewardActivityState, + repo: &Repo, + map: &mut HashMap, +) -> Result<()> +where + Repo: DailyRewardRepository, + Txn: Transaction, +{ + if state.detected.is_some() { + return Ok(()); + } + + let Some(sample) = state.pending_sample.clone() else { + return Ok(()); + }; + + let Some(entry) = map.get(&category) else { + return Ok(()); + }; + + if entry.claimable_since.is_some() || entry.claimed_at.is_some() { + // The reward is already ready or claimed; keep the existing activity sample. + return Ok(()); + } + + let params = DailyRewardMarkReady { + user_id, + date_utc: date, + category, + first_detected_at: None, + last_detected_at: None, + claimable_since: None, + activity_sample: Some(sample), + }; + + let updated = repo.mark_ready(txn, params).await?; + map.insert(category, updated); + Ok(()) +} + +#[cfg(test)] +mod get_today_cache_tests { + use super::*; + use academy_auth_contracts::{Authentication, MockAuthService}; + use academy_cache_contracts::MockCacheService; + use academy_core_coin_contracts::coin::MockCoinService; + use academy_core_daily_rewards_contracts::{ + DailyRewardClaimTotals, DailyRewardsSnapshot, MockDailyRewardActivityService, + }; + use academy_models::{ + Sha256Hash, + auth::AccessToken, + session::{SessionId, SessionRefreshTokenHash}, + user::UserId, + }; + use academy_persistence_contracts::{ + MockDatabase, MockTransaction, daily_rewards::MockDailyRewardRepository, + }; + use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; + use chrono::NaiveDate; + use mockall::predicate; + use std::future; + + #[tokio::test] + async fn returns_cached_snapshot_without_refreshing() { + let user_id = UserId::from(uuid::Uuid::new_v4()); + let date = NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(); + let now = date.and_hms_opt(12, 0, 0).unwrap().and_utc(); + let token = AccessToken::new("token"); + + let snapshot = DailyRewardsSnapshot { + date_utc: date, + feature_enabled: true, + rewards: Vec::new(), + claim_totals: DailyRewardClaimTotals { + available_coins: 0, + claimed_today: 0, + }, + }; + + let mut auth = MockAuthService::new(); + auth.expect_authenticate() + .once() + .with(predicate::eq(token.clone())) + .return_once(move |_| { + Box::pin(future::ready(Ok(Authentication { + user_id, + session_id: SessionId::from(uuid::Uuid::new_v4()), + refresh_token_hash: SessionRefreshTokenHash::new(Sha256Hash::default()), + admin: false, + email_verified: true, + }))) + }); + + let cache_key = cache_key(user_id, date); + let mut cache = MockCacheService::new(); + { + let expected_snapshot = snapshot.clone(); + cache + .expect_get() + .once() + .with(predicate::eq(cache_key.clone())) + .return_once(move |_| Box::pin(future::ready(Ok(Some(expected_snapshot))))); + cache.expect_set::().never(); + } + + let db = MockDatabase::build_expect_rollback(); + + let mut repo = MockDailyRewardRepository::new(); + repo.expect_list_by_user_and_date() + .once() + .return_once(move |_, _, _| { + Box::pin(future::ready(Err(anyhow::anyhow!( + "failed to load entries" + )))) + }); + repo.expect_upsert_entry().never(); + repo.expect_mark_ready().never(); + repo.expect_mark_claimed().never(); + + let mut activity = MockDailyRewardActivityService::new(); + activity.expect_detect().never(); + + let coin = MockCoinService::::new(); + + let time = MockTimeService::new().with_now(now); + + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + db, + auth, + repo, + coin, + cache, + activity, + MockIdService::new(), + time, + DailyRewardFeatureConfig { + enable: true, + coins: DailyRewardCoinsConfig { + arrival: 5, + lecture: 20, + practice: 10, + lab: 30, + }, + cache_ttl: Some(Duration::from_secs(60)), + }, + ); + + let response = sut.get_today(&token).await.unwrap(); + assert_eq!(response.snapshot.date_utc, date); + assert!(response.snapshot.rewards.is_empty()); + assert_eq!(response.snapshot.claim_totals.available_coins, 0); + assert_eq!(response.snapshot.claim_totals.claimed_today, 0); + } +} + +#[cfg(test)] +mod refresh_entries_tests { + use super::*; + use crate::DailyRewardFeatureConfig; + use academy_auth_contracts::MockAuthService; + use academy_cache_contracts::MockCacheService; + use academy_core_coin_contracts::coin::MockCoinService; + use academy_core_daily_rewards_contracts::DailyRewardActivitySnapshot; + use academy_core_daily_rewards_contracts::MockDailyRewardActivityService; + use academy_persistence_contracts::{ + MockDatabase, MockTransaction, daily_rewards::MockDailyRewardRepository, + }; + use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; + use chrono::TimeZone; + use mockall::predicate; + use std::future; + + fn claimed_entry( + user_id: UserId, + date: NaiveDate, + claimed_at: DateTime, + coins: i32, + ) -> DailyRewardEntry { + let detected_at = claimed_at - chrono::Duration::minutes(5); + DailyRewardEntry { + id: uuid::Uuid::new_v4(), + user_id, + date_utc: date, + category: DailyRewardCategory::Arrival, + coins, + first_detected_at: Some(detected_at), + last_detected_at: Some(detected_at), + claimable_since: Some(detected_at), + claimed_at: Some(claimed_at), + activity_sample: None, + created_at: claimed_at, + updated_at: claimed_at, + } + } + + fn entry( + user_id: UserId, + category: DailyRewardCategory, + date: NaiveDate, + coins: i32, + ) -> DailyRewardEntry { + DailyRewardEntry { + id: uuid::Uuid::new_v4(), + user_id, + date_utc: date, + category, + coins, + first_detected_at: None, + last_detected_at: None, + claimable_since: None, + claimed_at: None, + activity_sample: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[tokio::test] + async fn refresh_entries_keeps_claimed_coin_amounts() { + let user_id = UserId::from(uuid::Uuid::new_v4()); + let date = NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(); + let day_start = date.and_hms_opt(0, 0, 0).unwrap().and_utc(); + let day_end = day_start + chrono::Duration::days(1); + let claimed_at = Utc.with_ymd_and_hms(2025, 11, 1, 8, 0, 0).unwrap(); + + let arrival_entry = claimed_entry(user_id, date, claimed_at, 20); + let lecture_entry = entry(user_id, DailyRewardCategory::Lecture, date, 20); + let practice_entry = entry(user_id, DailyRewardCategory::Practice, date, 10); + let lab_entry = entry(user_id, DailyRewardCategory::Lab, date, 30); + + let entries = vec![ + arrival_entry.clone(), + lecture_entry, + practice_entry, + lab_entry, + ]; + + let mut repo = MockDailyRewardRepository::new(); + { + let entries = entries.clone(); + repo.expect_list_by_user_and_date() + .once() + .with( + predicate::always(), + predicate::eq(user_id), + predicate::eq(date), + ) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(entries)))); + } + repo.expect_upsert_entry().never(); + + let mut activity = MockDailyRewardActivityService::new(); + let expected_day_start = day_start; + let expected_day_end = day_end; + let expected_user = user_id; + activity + .expect_detect() + .once() + .withf(move |token, user, start, end| { + token.is_none() + && user == &expected_user + && start == &expected_day_start + && end == &expected_day_end + }) + .return_once(|_, _, _, _| { + Box::pin(future::ready(Ok(DailyRewardActivitySnapshot::default()))) + }); + + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + MockDatabase::new(), + MockAuthService::::new(), + repo, + MockCoinService::::new(), + MockCacheService::new(), + activity, + MockIdService::new(), + MockTimeService::new(), + DailyRewardFeatureConfig { + enable: true, + coins: DailyRewardCoinsConfig { + arrival: 5, + lecture: 20, + practice: 10, + lab: 30, + }, + cache_ttl: None, + }, + ); + + let mut txn = MockTransaction::new(); + + let refreshed = sut + .refresh_entries(&mut txn, None, user_id, date, day_start, day_end) + .await + .unwrap(); + + let stored_arrival = refreshed + .entries + .get(&DailyRewardCategory::Arrival) + .expect("arrival entry missing"); + assert_eq!(stored_arrival.coins, 20); + assert_eq!(stored_arrival.claimed_at, Some(claimed_at)); + } +} diff --git a/academy_core/daily_rewards/impl/src/tests/activity.rs b/academy_core/daily_rewards/impl/src/tests/activity.rs new file mode 100644 index 00000000..81c47d43 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/tests/activity.rs @@ -0,0 +1,255 @@ +use std::future; + +use academy_auth_contracts::{Authentication, MockAuthService}; +use academy_cache_contracts::MockCacheService; +use academy_core_coin_contracts::coin::MockCoinService; +use academy_core_daily_rewards_contracts::{ + DailyRewardActivity, DailyRewardActivitySnapshot, DailyRewardActivityState, + DailyRewardCategory, DailyRewardFeatureService, DailyRewardGetResponse, DailyRewardStatus, + MockDailyRewardActivityService, +}; +use academy_models::{ + Sha256Hash, + auth::AccessToken, + daily_rewards::DailyRewardEntry, + session::{SessionId, SessionRefreshTokenHash}, + user::UserId, +}; +use academy_persistence_contracts::{ + MockDatabase, MockTransaction, + daily_rewards::{DailyRewardMarkReady, MockDailyRewardRepository}, +}; +use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; +use mockall::predicate; +use serde_json::json; +use uuid::Uuid; + +use crate::{DailyRewardFeatureConfig, DailyRewardFeatureServiceImpl}; + +fn make_entry( + user_id: UserId, + category: DailyRewardCategory, + date: NaiveDate, + coins: i32, + claimable_since: Option>, +) -> DailyRewardEntry { + DailyRewardEntry { + id: Uuid::new_v4(), + user_id, + date_utc: date, + category, + coins, + first_detected_at: claimable_since, + last_detected_at: claimable_since, + claimable_since, + claimed_at: None, + activity_sample: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +fn mock_auth(user_id: UserId) -> MockAuthService { + let mut auth = MockAuthService::new(); + auth.expect_authenticate() + .once() + .with(predicate::eq(AccessToken::new("token"))) + .return_once(move |_| { + Box::pin(future::ready(Ok(Authentication { + user_id, + session_id: SessionId::from(Uuid::new_v4()), + refresh_token_hash: SessionRefreshTokenHash::new(Sha256Hash::default()), + admin: false, + email_verified: true, + }))) + }); + auth +} + +#[tokio::test] +async fn get_today_marks_practice_and_lab_ready_when_detected() { + let user_id = UserId::from(Uuid::new_v4()); + let now = Utc.with_ymd_and_hms(2025, 11, 4, 15, 0, 0).unwrap(); + let date = now.date_naive(); + let day_start = date.and_hms_opt(0, 0, 0).unwrap().and_utc(); + let day_end = day_start + chrono::Duration::days(1); + + let practice_entry = make_entry(user_id, DailyRewardCategory::Practice, date, 10, None); + let lab_entry = make_entry(user_id, DailyRewardCategory::Lab, date, 30, None); + + let arrival_ready_at = now - chrono::Duration::hours(3); + let arrival_entry = make_entry( + user_id, + DailyRewardCategory::Arrival, + date, + 20, + Some(arrival_ready_at), + ); + + let lecture_entry = make_entry( + user_id, + DailyRewardCategory::Lecture, + date, + 20, + Some(now - chrono::Duration::hours(1)), + ); + + let base_entries = vec![ + arrival_entry.clone(), + lecture_entry.clone(), + practice_entry.clone(), + lab_entry.clone(), + ]; + + let practice_sample = json!({ "task_id": "practice-task" }); + let lab_sample = json!({ "task_id": "lab-task" }); + + let practice_first = now - chrono::Duration::minutes(20); + let practice_last = now - chrono::Duration::minutes(5); + let lab_first = now - chrono::Duration::minutes(10); + let lab_last = now - chrono::Duration::minutes(2); + + let activity_snapshot = DailyRewardActivitySnapshot { + lecture: DailyRewardActivityState::default(), + practice: DailyRewardActivityState { + detected: Some(DailyRewardActivity { + first_detected_at: practice_first, + last_detected_at: practice_last, + activity_sample: Some(practice_sample.clone()), + }), + pending_sample: None, + unavailable_reason: None, + }, + lab: DailyRewardActivityState { + detected: Some(DailyRewardActivity { + first_detected_at: lab_first, + last_detected_at: lab_last, + activity_sample: Some(lab_sample.clone()), + }), + pending_sample: None, + unavailable_reason: None, + }, + }; + + let mut repo = MockDailyRewardRepository::new(); + { + let entries = base_entries.clone(); + repo.expect_list_by_user_and_date() + .once() + .with( + predicate::always(), + predicate::eq(user_id), + predicate::eq(date), + ) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(entries)))); + } + + { + let mut updated_practice = practice_entry.clone(); + let practice_expect_sample = practice_sample.clone(); + repo.expect_mark_ready() + .with( + predicate::always(), + predicate::function(move |params: &DailyRewardMarkReady| { + params.user_id == user_id + && params.category == DailyRewardCategory::Practice + && params.date_utc == date + && params.first_detected_at == Some(practice_first) + && params.last_detected_at == Some(practice_last) + && params.claimable_since == Some(practice_first) + && params.activity_sample == Some(practice_expect_sample.clone()) + }), + ) + .return_once(move |_, params| { + updated_practice.first_detected_at = params.first_detected_at; + updated_practice.last_detected_at = params.last_detected_at; + updated_practice.claimable_since = params.claimable_since; + updated_practice.activity_sample = params.activity_sample.clone(); + Box::pin(future::ready(Ok(updated_practice))) + }); + } + + { + let mut updated_lab = lab_entry.clone(); + let lab_expect_sample = lab_sample.clone(); + repo.expect_mark_ready() + .with( + predicate::always(), + predicate::function(move |params: &DailyRewardMarkReady| { + params.user_id == user_id + && params.category == DailyRewardCategory::Lab + && params.date_utc == date + && params.first_detected_at == Some(lab_first) + && params.last_detected_at == Some(lab_last) + && params.claimable_since == Some(lab_first) + && params.activity_sample == Some(lab_expect_sample.clone()) + }), + ) + .return_once(move |_, params| { + updated_lab.first_detected_at = params.first_detected_at; + updated_lab.last_detected_at = params.last_detected_at; + updated_lab.claimable_since = params.claimable_since; + updated_lab.activity_sample = params.activity_sample.clone(); + Box::pin(future::ready(Ok(updated_lab))) + }); + } + + repo.expect_upsert_entry().never(); + repo.expect_mark_claimed().never(); + + let mut activity = MockDailyRewardActivityService::new(); + activity + .expect_detect() + .once() + .withf(move |token, user, start, end| { + token.is_some() && user == &user_id && start == &day_start && end == &day_end + }) + .return_once(move |_, _, _, _| Box::pin(future::ready(Ok(activity_snapshot)))); + + let db = MockDatabase::build(true); + let auth = mock_auth(user_id); + let coin = MockCoinService::new(); + let cache = MockCacheService::new(); + let time = MockTimeService::new().with_now(now); + + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + db, + auth, + repo, + coin, + cache, + activity, + MockIdService::new(), + time, + DailyRewardFeatureConfig::default(), + ); + + let response: DailyRewardGetResponse = sut + .get_today(&AccessToken::new("token")) + .await + .expect("get_today succeeds"); + + let practice_reward = response + .snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Practice) + .expect("practice reward present"); + let lab_reward = response + .snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Lab) + .expect("lab reward present"); + + assert_eq!(practice_reward.status, DailyRewardStatus::Ready); + assert_eq!(lab_reward.status, DailyRewardStatus::Ready); + assert_eq!(practice_reward.claimable_since, Some(practice_first)); + assert_eq!(lab_reward.claimable_since, Some(lab_first)); + assert_eq!( + practice_reward.activity_sample, + Some(practice_sample.clone()) + ); + assert_eq!(lab_reward.activity_sample, Some(lab_sample.clone())); +} diff --git a/academy_core/daily_rewards/impl/src/tests/claim.rs b/academy_core/daily_rewards/impl/src/tests/claim.rs new file mode 100644 index 00000000..5db250ec --- /dev/null +++ b/academy_core/daily_rewards/impl/src/tests/claim.rs @@ -0,0 +1,239 @@ +use std::future; + +use academy_auth_contracts::{Authentication, MockAuthService}; +use academy_cache_contracts::MockCacheService; +use academy_core_coin_contracts::coin::MockCoinService; +use academy_core_daily_rewards_contracts::{ + DailyRewardActivitySnapshot, DailyRewardClaimError, DailyRewardFeatureService, + MockDailyRewardActivityService, +}; +use academy_models::{ + Sha256Hash, + auth::AccessToken, + coin::Balance, + coin::TransactionDescription, + daily_rewards::{DailyRewardCategory, DailyRewardEntry}, + session::{SessionId, SessionRefreshTokenHash}, + user::UserId, +}; +use academy_persistence_contracts::{ + MockDatabase, MockTransaction, + daily_rewards::{DailyRewardMarkClaimed, MockDailyRewardRepository}, +}; +use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; +use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, TimeZone, Utc}; +use mockall::predicate; +use uuid::Uuid; + +use crate::DailyRewardFeatureServiceImpl; + +fn make_entry( + category: DailyRewardCategory, + coins: i32, + date: NaiveDate, + claimable_since: Option>, + claimed_at: Option>, +) -> DailyRewardEntry { + DailyRewardEntry { + id: Uuid::new_v4(), + user_id: UserId::from(Uuid::new_v4()), + date_utc: date, + category, + coins, + first_detected_at: claimable_since, + last_detected_at: claimable_since, + claimable_since, + claimed_at, + activity_sample: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +fn auth_service(user_id: UserId) -> MockAuthService { + let mut auth = MockAuthService::new(); + auth.expect_authenticate() + .once() + .with(predicate::eq(AccessToken::new("token"))) + .return_once(move |_| { + Box::pin(future::ready(Ok(Authentication { + user_id, + session_id: SessionId::from(Uuid::new_v4()), + refresh_token_hash: SessionRefreshTokenHash::new(Sha256Hash::default()), + admin: false, + email_verified: true, + }))) + }); + auth +} + +fn activity_service() -> MockDailyRewardActivityService { + let mut activity = MockDailyRewardActivityService::new(); + activity + .expect_detect() + .once() + .withf(|token, _, _, _| token.is_some()) + .return_once(|_, _, _, _| { + Box::pin(future::ready(Ok(DailyRewardActivitySnapshot::default()))) + }); + activity +} + +fn base_entries(user_id: UserId, date: NaiveDate, now: DateTime) -> [DailyRewardEntry; 4] { + [ + DailyRewardEntry { + user_id, + ..make_entry( + DailyRewardCategory::Arrival, + 20, + date, + Some(now - ChronoDuration::hours(1)), + None, + ) + }, + DailyRewardEntry { + user_id, + ..make_entry( + DailyRewardCategory::Lecture, + 20, + date, + Some(now - ChronoDuration::minutes(10)), + None, + ) + }, + DailyRewardEntry { + user_id, + ..make_entry(DailyRewardCategory::Practice, 10, date, None, None) + }, + DailyRewardEntry { + user_id, + ..make_entry(DailyRewardCategory::Lab, 30, date, None, None) + }, + ] +} + +#[tokio::test] +async fn claim_success() { + // Arrange + let user_id = UserId::from(Uuid::new_v4()); + let date = NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(); + let now = Utc.with_ymd_and_hms(2025, 11, 1, 12, 0, 0).unwrap(); + + let entries_array = base_entries(user_id, date, now); + let lecture_entry = entries_array[1].clone(); + let entries_vec = Vec::from(entries_array); + + let mut repo = MockDailyRewardRepository::new(); + repo.expect_list_by_user_and_date() + .once() + .with( + predicate::always(), + predicate::eq(user_id), + predicate::eq(date), + ) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(entries_vec)))); + + let lecture_for_claim = lecture_entry.clone(); + repo.expect_mark_claimed() + .once() + .with( + predicate::always(), + predicate::function(move |params: &DailyRewardMarkClaimed| { + params.user_id == user_id + && params.category == DailyRewardCategory::Lecture + && params.date_utc == date + }), + ) + .return_once(move |_, params| { + let mut updated = lecture_for_claim; + updated.claimed_at = Some(params.claimed_at); + Box::pin(future::ready(Ok(updated))) + }); + + let description = + TransactionDescription::try_from("Daily reward - lecture".to_string()).unwrap(); + let coin = MockCoinService::new().with_add_coins( + user_id, + 20, + false, + Some(description), + false, + Ok(Balance::default()), + ); + + let db = MockDatabase::build(true); + let auth = auth_service(user_id); + let activity = activity_service(); + let time = MockTimeService::new().with_now(now); + + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + db, + auth, + repo, + coin, + MockCacheService::new(), + activity, + MockIdService::new(), + time, + Default::default(), + ); + + // Act + let result = sut + .claim(&AccessToken::new("token"), DailyRewardCategory::Lecture) + .await + .unwrap(); + + // Assert + assert_eq!(result.success.category, DailyRewardCategory::Lecture); + assert_eq!(result.success.coins, 20); + assert_eq!(result.success.claimed_at, now); +} + +#[tokio::test] +async fn claim_already_claimed() { + // Arrange + let user_id = UserId::from(Uuid::new_v4()); + let date = NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(); + let now = Utc.with_ymd_and_hms(2025, 11, 1, 12, 0, 0).unwrap(); + + let mut entries = base_entries(user_id, date, now); + entries[1].claimed_at = Some(now - ChronoDuration::minutes(5)); + let entries_vec = Vec::from(entries); + + let mut repo = MockDailyRewardRepository::new(); + repo.expect_list_by_user_and_date() + .once() + .with( + predicate::always(), + predicate::eq(user_id), + predicate::eq(date), + ) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(entries_vec)))); + + let db = MockDatabase::build(false); + let auth = auth_service(user_id); + let activity = activity_service(); + let time = MockTimeService::new().with_now(now); + + let coin = MockCoinService::new(); + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + db, + auth, + repo, + coin, + MockCacheService::new(), + activity, + MockIdService::new(), + time, + Default::default(), + ); + + // Act + let result = sut + .claim(&AccessToken::new("token"), DailyRewardCategory::Lecture) + .await; + + // Assert + assert!(matches!(result, Err(DailyRewardClaimError::AlreadyClaimed))); +} diff --git a/academy_core/daily_rewards/impl/src/tests/mod.rs b/academy_core/daily_rewards/impl/src/tests/mod.rs new file mode 100644 index 00000000..1d787d15 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/tests/mod.rs @@ -0,0 +1,21 @@ +use crate::{DailyRewardCoinsConfig, DailyRewardFeatureConfig}; + +pub mod activity; +pub mod claim; +pub mod ready; +pub mod snapshot; + +impl Default for DailyRewardFeatureConfig { + fn default() -> Self { + Self { + enable: true, + coins: DailyRewardCoinsConfig { + arrival: 20, + lecture: 20, + practice: 10, + lab: 30, + }, + cache_ttl: None, + } + } +} diff --git a/academy_core/daily_rewards/impl/src/tests/ready.rs b/academy_core/daily_rewards/impl/src/tests/ready.rs new file mode 100644 index 00000000..3ef1ae84 --- /dev/null +++ b/academy_core/daily_rewards/impl/src/tests/ready.rs @@ -0,0 +1,214 @@ +use std::{ + collections::HashMap, + future, + sync::{Arc, Mutex}, +}; + +use academy_auth_contracts::{Authentication, MockAuthService}; +use academy_cache_contracts::MockCacheService; +use academy_core_coin_contracts::coin::MockCoinService; +use academy_core_daily_rewards_contracts::{ + DailyRewardActivity, DailyRewardActivitySnapshot, DailyRewardActivityState, + DailyRewardFeatureService, DailyRewardStatus, MockDailyRewardActivityService, +}; +use academy_models::{ + Sha256Hash, + auth::AccessToken, + daily_rewards::{DailyRewardCategory, DailyRewardEntry}, + session::{SessionId, SessionRefreshTokenHash}, + user::UserId, +}; +use academy_persistence_contracts::{ + MockDatabase, MockTransaction, + daily_rewards::{DailyRewardMarkReady, MockDailyRewardRepository}, +}; +use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; +use chrono::{Duration as ChronoDuration, NaiveDate, TimeZone, Utc}; +use mockall::predicate; +use serde_json::json; +use uuid::Uuid; + +use crate::DailyRewardFeatureServiceImpl; + +fn auth_service(user_id: UserId) -> MockAuthService { + let mut auth = MockAuthService::new(); + auth.expect_authenticate() + .once() + .with(predicate::eq(AccessToken::new("token"))) + .return_once(move |_| { + Box::pin(future::ready(Ok(Authentication { + user_id, + session_id: SessionId::from(Uuid::new_v4()), + refresh_token_hash: SessionRefreshTokenHash::new(Sha256Hash::default()), + admin: false, + email_verified: true, + }))) + }); + auth +} + +fn entry( + user_id: UserId, + date: NaiveDate, + category: DailyRewardCategory, + coins: i32, + claimable_since: Option>, + claimed_at: Option>, +) -> DailyRewardEntry { + DailyRewardEntry { + id: Uuid::new_v4(), + user_id, + date_utc: date, + category, + coins, + first_detected_at: claimable_since, + last_detected_at: claimable_since, + claimable_since, + claimed_at, + activity_sample: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +#[tokio::test] +async fn get_today_marks_practice_and_lab_ready() { + let user_id = UserId::from(Uuid::new_v4()); + let date = NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(); + let now = Utc.with_ymd_and_hms(2025, 11, 2, 12, 0, 0).unwrap(); + let practice_ready_at = now - ChronoDuration::minutes(15); + let lab_ready_at = now - ChronoDuration::minutes(5); + + let arrival_entry = entry( + user_id, + date, + DailyRewardCategory::Arrival, + 20, + Some(now - ChronoDuration::hours(1)), + None, + ); + let lecture_entry = entry(user_id, date, DailyRewardCategory::Lecture, 20, None, None); + let practice_entry = entry(user_id, date, DailyRewardCategory::Practice, 10, None, None); + let lab_entry = entry(user_id, date, DailyRewardCategory::Lab, 30, None, None); + + let entries_map: HashMap = [ + (DailyRewardCategory::Arrival, arrival_entry), + (DailyRewardCategory::Lecture, lecture_entry), + (DailyRewardCategory::Practice, practice_entry), + (DailyRewardCategory::Lab, lab_entry), + ] + .into_iter() + .collect(); + let shared_entries = Arc::new(Mutex::new(entries_map)); + + let mut repo = MockDailyRewardRepository::new(); + let list_entries = Arc::clone(&shared_entries); + repo.expect_list_by_user_and_date() + .once() + .with( + predicate::always(), + predicate::eq(user_id), + predicate::eq(date), + ) + .return_once(move |_, _, _| { + let entries = list_entries + .lock() + .unwrap() + .values() + .cloned() + .collect::>(); + Box::pin(future::ready(Ok(entries))) + }); + + repo.expect_upsert_entry().never(); + + let mark_ready_entries = Arc::clone(&shared_entries); + repo.expect_mark_ready() + .times(2) + .returning(move |_, params: DailyRewardMarkReady| { + let mut guard = mark_ready_entries.lock().unwrap(); + let mut entry = guard.get(¶ms.category).cloned().unwrap(); + entry.first_detected_at = params.first_detected_at; + entry.last_detected_at = params.last_detected_at; + entry.claimable_since = params.claimable_since; + entry.activity_sample = params.activity_sample.clone(); + guard.insert(params.category, entry.clone()); + Box::pin(future::ready(Ok(entry))) + }); + + let mut activity = MockDailyRewardActivityService::new(); + activity + .expect_detect() + .once() + .withf(move |token, detected_user, _, _| token.is_some() && *detected_user == user_id) + .return_once(move |_, _, _, _| { + let practice_activity = DailyRewardActivity { + first_detected_at: practice_ready_at, + last_detected_at: practice_ready_at, + activity_sample: Some(json!({"taskId": "practice"})), + }; + let lab_activity = DailyRewardActivity { + first_detected_at: lab_ready_at, + last_detected_at: lab_ready_at, + activity_sample: Some(json!({"taskId": "lab"})), + }; + let snapshot = DailyRewardActivitySnapshot { + practice: DailyRewardActivityState { + detected: Some(practice_activity), + pending_sample: None, + unavailable_reason: None, + }, + lab: DailyRewardActivityState { + detected: Some(lab_activity), + pending_sample: None, + unavailable_reason: None, + }, + ..Default::default() + }; + Box::pin(future::ready(Ok(snapshot))) + }); + + let db = MockDatabase::build(true); + let auth = auth_service(user_id); + let coin = MockCoinService::new(); + let cache = MockCacheService::new(); + let ids = MockIdService::new(); + let time = MockTimeService::new().with_now(now); + + let sut = DailyRewardFeatureServiceImpl::new_for_tests( + db, + auth, + repo, + coin, + cache, + activity, + ids, + time, + Default::default(), + ); + + let response = sut + .get_today(&AccessToken::new("token")) + .await + .expect("get_today should succeed"); + + let practice_reward = response + .snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Practice) + .expect("practice reward present"); + assert_eq!(practice_reward.status, DailyRewardStatus::Ready); + assert_eq!(practice_reward.claimable_since, Some(practice_ready_at)); + + let lab_reward = response + .snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Lab) + .expect("lab reward present"); + assert_eq!(lab_reward.status, DailyRewardStatus::Ready); + assert_eq!(lab_reward.claimable_since, Some(lab_ready_at)); + + assert_eq!(response.snapshot.claim_totals.available_coins, 60); +} diff --git a/academy_core/daily_rewards/impl/src/tests/snapshot.rs b/academy_core/daily_rewards/impl/src/tests/snapshot.rs new file mode 100644 index 00000000..c6e2b17e --- /dev/null +++ b/academy_core/daily_rewards/impl/src/tests/snapshot.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; + +use academy_core_daily_rewards_contracts::{DailyRewardStatus, DailyRewardUnavailableReason}; +use academy_models::daily_rewards::{DailyRewardCategory, DailyRewardEntry}; +use chrono::{NaiveDate, TimeZone, Utc}; +use uuid::Uuid; + +use crate::service::{RefreshedRewards, build_snapshot}; + +fn entry( + category: DailyRewardCategory, + coins: i32, + date: NaiveDate, + claimable_since: Option>, + claimed_at: Option>, +) -> DailyRewardEntry { + DailyRewardEntry { + id: Uuid::new_v4(), + user_id: Uuid::new_v4().into(), + date_utc: date, + category, + coins, + first_detected_at: claimable_since, + last_detected_at: claimable_since, + claimable_since, + claimed_at, + activity_sample: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +#[test] +fn build_snapshot_assigns_statuses_and_totals() { + let date = NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(); + let ready_time = Utc.with_ymd_and_hms(2025, 11, 1, 7, 15, 0).unwrap(); + let claimed_time = Utc.with_ymd_and_hms(2025, 11, 1, 6, 0, 0).unwrap(); + + let mut entries = HashMap::new(); + entries.insert( + DailyRewardCategory::Arrival, + entry(DailyRewardCategory::Arrival, 20, date, None, None), + ); + entries.insert( + DailyRewardCategory::Lecture, + entry( + DailyRewardCategory::Lecture, + 20, + date, + Some(claimed_time - chrono::Duration::minutes(5)), + Some(claimed_time), + ), + ); + entries.insert( + DailyRewardCategory::Practice, + entry(DailyRewardCategory::Practice, 10, date, None, None), + ); + entries.insert( + DailyRewardCategory::Lab, + entry(DailyRewardCategory::Lab, 30, date, Some(ready_time), None), + ); + + let mut unavailability = HashMap::new(); + unavailability.insert(DailyRewardCategory::Arrival, None); + unavailability.insert(DailyRewardCategory::Lecture, None); + unavailability.insert( + DailyRewardCategory::Practice, + Some(DailyRewardUnavailableReason::NoRecommendation), + ); + unavailability.insert(DailyRewardCategory::Lab, None); + + let refreshed = RefreshedRewards { + entries, + unavailability, + }; + + let snapshot = build_snapshot(date, refreshed); + + let arrival = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Arrival) + .unwrap(); + let lecture = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Lecture) + .unwrap(); + let practice = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Practice) + .unwrap(); + let lab = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Lab) + .unwrap(); + + assert_eq!(arrival.status, DailyRewardStatus::Pending); + assert_eq!(lecture.status, DailyRewardStatus::Claimed); + assert_eq!(practice.status, DailyRewardStatus::Unavailable); + assert_eq!(lab.status, DailyRewardStatus::Ready); + assert_eq!(snapshot.claim_totals.available_coins, 30); + assert_eq!(snapshot.claim_totals.claimed_today, 20); +} + +#[test] +fn build_snapshot_uses_entry_coin_amounts() { + let date = NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(); + let ready_time = Utc.with_ymd_and_hms(2025, 11, 2, 9, 0, 0).unwrap(); + let claimed_time = Utc.with_ymd_and_hms(2025, 11, 2, 8, 0, 0).unwrap(); + + let mut entries = HashMap::new(); + entries.insert( + DailyRewardCategory::Arrival, + entry( + DailyRewardCategory::Arrival, + 20, + date, + Some(claimed_time - chrono::Duration::minutes(15)), + Some(claimed_time), + ), + ); + entries.insert( + DailyRewardCategory::Lab, + entry(DailyRewardCategory::Lab, 7, date, Some(ready_time), None), + ); + + let mut unavailability = HashMap::new(); + unavailability.insert(DailyRewardCategory::Arrival, None); + unavailability.insert(DailyRewardCategory::Lab, None); + + let refreshed = RefreshedRewards { + entries, + unavailability, + }; + + let snapshot = build_snapshot(date, refreshed); + + let arrival = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Arrival) + .unwrap(); + let lab = snapshot + .rewards + .iter() + .find(|reward| reward.category == DailyRewardCategory::Lab) + .unwrap(); + + assert_eq!(arrival.coins, 20); + assert_eq!(lab.coins, 7); + assert_eq!(snapshot.claim_totals.claimed_today, 20); + assert_eq!(snapshot.claim_totals.available_coins, 7); +} diff --git a/academy_models/Cargo.toml b/academy_models/Cargo.toml index 9059ae9c..d48db335 100644 --- a/academy_models/Cargo.toml +++ b/academy_models/Cargo.toml @@ -19,9 +19,10 @@ nutype.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true url.workspace = true uuid.workspace = true +postgres-types.workspace = true [dev-dependencies] -serde_json.workspace = true diff --git a/academy_models/src/daily_rewards.rs b/academy_models/src/daily_rewards.rs new file mode 100644 index 00000000..52a6d18b --- /dev/null +++ b/academy_models/src/daily_rewards.rs @@ -0,0 +1,144 @@ +use std::{fmt, str::FromStr}; + +use postgres_types::private::BytesMut; +use postgres_types::{FromSql, IsNull, ToSql, Type}; + +use chrono::{DateTime, NaiveDate, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +use crate::user::UserId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DailyRewardCategory { + Arrival, + Lecture, + Practice, + Lab, +} + +impl DailyRewardCategory { + pub const ALL: [Self; 4] = [Self::Arrival, Self::Lecture, Self::Practice, Self::Lab]; + + pub fn as_str(&self) -> &'static str { + match self { + Self::Arrival => "arrival", + Self::Lecture => "lecture", + Self::Practice => "practice", + Self::Lab => "lab", + } + } +} + +impl fmt::Display for DailyRewardCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Error)] +#[error("Unknown daily reward category: {0}")] +pub struct ParseDailyRewardCategoryError(String); + +impl FromStr for DailyRewardCategory { + type Err = ParseDailyRewardCategoryError; + + fn from_str(value: &str) -> Result { + match value { + "arrival" => Ok(Self::Arrival), + "lecture" => Ok(Self::Lecture), + "practice" => Ok(Self::Practice), + "lab" => Ok(Self::Lab), + other => Err(ParseDailyRewardCategoryError(other.into())), + } + } +} + +impl TryFrom<&str> for DailyRewardCategory { + type Error = ParseDailyRewardCategoryError; + + fn try_from(value: &str) -> Result { + Self::from_str(value) + } +} + +impl ToSql for DailyRewardCategory { + fn to_sql( + &self, + ty: &Type, + out: &mut BytesMut, + ) -> Result> { + if ty.name() != "daily_reward_category" { + return Err(format!( + "DailyRewardCategory does not support Postgres type {}.{}", + ty.schema(), + ty.name() + ) + .into()); + } + + out.extend_from_slice(self.as_str().as_bytes()); + Ok(IsNull::No) + } + + fn accepts(ty: &Type) -> bool { + ty.name() == "daily_reward_category" + } + + postgres_types::to_sql_checked!(); +} + +impl<'a> FromSql<'a> for DailyRewardCategory { + fn from_sql( + ty: &Type, + raw: &'a [u8], + ) -> Result> { + if ty.name() != "daily_reward_category" { + return Err(format!( + "DailyRewardCategory does not support Postgres type {}.{}", + ty.schema(), + ty.name() + ) + .into()); + } + + let value = std::str::from_utf8(raw)?; + DailyRewardCategory::from_str(value).map_err(|err| Box::new(err) as _) + } + + fn accepts(ty: &Type) -> bool { + ty.name() == "daily_reward_category" + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DailyRewardEntry { + pub id: Uuid, + pub user_id: UserId, + pub date_utc: NaiveDate, + pub category: DailyRewardCategory, + pub coins: i32, + pub first_detected_at: Option>, + pub last_detected_at: Option>, + pub claimable_since: Option>, + pub claimed_at: Option>, + pub activity_sample: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl DailyRewardEntry { + #[must_use] + pub fn is_ready(&self) -> bool { + self.claimable_since.is_some() && self.claimed_at.is_none() + } + + #[must_use] + pub fn is_claimed(&self) -> bool { + self.claimed_at.is_some() + } +} diff --git a/academy_models/src/lib.rs b/academy_models/src/lib.rs index a488e94b..6c46f941 100644 --- a/academy_models/src/lib.rs +++ b/academy_models/src/lib.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; pub mod auth; pub mod coin; pub mod contact; +pub mod daily_rewards; pub mod email_address; pub mod heart; mod macros; diff --git a/academy_persistence/contracts/Cargo.toml b/academy_persistence/contracts/Cargo.toml index 7c518c9b..de021852 100644 --- a/academy_persistence/contracts/Cargo.toml +++ b/academy_persistence/contracts/Cargo.toml @@ -18,4 +18,6 @@ anyhow.workspace = true chrono.workspace = true futures.workspace = true mockall = { workspace = true, optional = true } +serde_json.workspace = true thiserror.workspace = true +uuid.workspace = true diff --git a/academy_persistence/contracts/src/daily_rewards.rs b/academy_persistence/contracts/src/daily_rewards.rs new file mode 100644 index 00000000..d15792b2 --- /dev/null +++ b/academy_persistence/contracts/src/daily_rewards.rs @@ -0,0 +1,78 @@ +use std::future::Future; + +use academy_models::{ + daily_rewards::{DailyRewardCategory, DailyRewardEntry}, + user::UserId, +}; +use chrono::{DateTime, NaiveDate, Utc}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct DailyRewardEntryUpsert { + pub id: Uuid, + pub user_id: UserId, + pub date_utc: NaiveDate, + pub category: DailyRewardCategory, + pub coins: i32, +} + +#[derive(Debug, Clone)] +pub struct DailyRewardMarkReady { + pub user_id: UserId, + pub date_utc: NaiveDate, + pub category: DailyRewardCategory, + pub first_detected_at: Option>, + pub last_detected_at: Option>, + pub claimable_since: Option>, + pub activity_sample: Option, +} + +#[derive(Debug, Clone)] +pub struct DailyRewardMarkClaimed { + pub user_id: UserId, + pub date_utc: NaiveDate, + pub category: DailyRewardCategory, + pub claimed_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum DailyRewardMarkClaimedError { + #[error("Daily reward is not yet ready to claim.")] + NotReady, + #[error("Daily reward already claimed.")] + AlreadyClaimed, + #[error("Daily reward entry not found.")] + NotFound, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[cfg_attr(feature = "mock", mockall::automock)] +pub trait DailyRewardRepository: Send + Sync + 'static { + fn list_by_user_and_date( + &self, + txn: &mut Txn, + user_id: UserId, + date_utc: NaiveDate, + ) -> impl Future>> + Send; + + fn upsert_entry( + &self, + txn: &mut Txn, + params: DailyRewardEntryUpsert, + ) -> impl Future> + Send; + + fn mark_ready( + &self, + txn: &mut Txn, + params: DailyRewardMarkReady, + ) -> impl Future> + Send; + + fn mark_claimed( + &self, + txn: &mut Txn, + params: DailyRewardMarkClaimed, + ) -> impl Future> + Send; +} diff --git a/academy_persistence/contracts/src/lib.rs b/academy_persistence/contracts/src/lib.rs index e3de7bcf..a1e62730 100644 --- a/academy_persistence/contracts/src/lib.rs +++ b/academy_persistence/contracts/src/lib.rs @@ -1,6 +1,7 @@ use std::future::Future; pub mod coin; +pub mod daily_rewards; pub mod heart; pub mod mfa; pub mod oauth2; diff --git a/academy_persistence/postgres/Cargo.toml b/academy_persistence/postgres/Cargo.toml index 96beddd6..0f5b2afa 100644 --- a/academy_persistence/postgres/Cargo.toml +++ b/academy_persistence/postgres/Cargo.toml @@ -19,13 +19,24 @@ academy_persistence_contracts.workspace = true academy_utils.workspace = true anyhow.workspace = true bb8 = { version = "0.9.0", default-features = false } -bb8-postgres = { version = "0.9.0", default-features = false, features = ["with-chrono-0_4", "with-uuid-1"] } +bb8-postgres = { version = "0.9.0", default-features = false, features = [ + "with-chrono-0_4", + "with-uuid-1", + "with-serde_json-1", +] } chrono.workspace = true clorinde.path = "./clorinde" futures.workspace = true ouroboros = { version = "0.18.5", default-features = false } +serde_json.workspace = true tracing.workspace = true uuid.workspace = true +tokio-postgres = { version = "0.7.15", default-features = false, features = [ + "with-chrono-0_4", + "with-uuid-1", + "with-serde_json-1", +] } +postgres-types = { workspace = true, features = ["with-serde_json-1", "with-chrono-0_4", "with-uuid-1"] } [dev-dependencies] academy_config.workspace = true diff --git a/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/down.sql b/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/down.sql new file mode 100644 index 00000000..1cfd31c0 --- /dev/null +++ b/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/down.sql @@ -0,0 +1,4 @@ +drop index if exists daily_reward_entries_claimable_idx; +drop index if exists daily_reward_entries_user_date_category_idx; +drop table if exists daily_reward_entries; +drop type if exists daily_reward_category; diff --git a/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/up.sql b/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/up.sql new file mode 100644 index 00000000..0e2699e9 --- /dev/null +++ b/academy_persistence/postgres/migrations/2025-10-31-130000_create_daily_reward_entries/up.sql @@ -0,0 +1,22 @@ +create type daily_reward_category as enum ('arrival', 'lecture', 'practice', 'lab'); + +create table daily_reward_entries ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + date_utc date not null, + category daily_reward_category not null, + coins integer not null check (coins >= 0), + first_detected_at timestamptz, + last_detected_at timestamptz, + claimable_since timestamptz, + claimed_at timestamptz, + activity_sample jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint chk_claimable_since check (claimable_since is null or first_detected_at is not null), + constraint chk_claimed_at check (claimed_at is null or claimable_since is not null) +); + +create unique index daily_reward_entries_user_date_category_idx on daily_reward_entries (user_id, date_utc, category); +create index daily_reward_entries_claimable_idx on daily_reward_entries (user_id, date_utc) where claimable_since is not null and claimed_at is null; + diff --git a/academy_persistence/postgres/src/daily_rewards.rs b/academy_persistence/postgres/src/daily_rewards.rs new file mode 100644 index 00000000..41702e67 --- /dev/null +++ b/academy_persistence/postgres/src/daily_rewards.rs @@ -0,0 +1,226 @@ +use academy_di::Build; +use academy_models::{ + daily_rewards::{DailyRewardCategory, DailyRewardEntry}, + user::UserId, +}; +use academy_persistence_contracts::daily_rewards::{ + DailyRewardEntryUpsert, DailyRewardMarkClaimed, DailyRewardMarkClaimedError, + DailyRewardMarkReady, DailyRewardRepository, +}; +use academy_utils::trace_instrument; +use anyhow::anyhow; +use bb8_postgres::tokio_postgres::Row; +use chrono::{DateTime, FixedOffset, NaiveDate, Utc}; +use postgres_types::Json; +use serde_json::Value; + +use crate::PostgresTransaction; + +#[derive(Debug, Clone, Build)] +pub struct PostgresDailyRewardRepository; + +impl PostgresDailyRewardRepository { + fn map_entry(row: Row) -> anyhow::Result { + let map_ts = |value: Option>| value.map(|dt| dt.with_timezone(&Utc)); + + Ok(DailyRewardEntry { + id: row.try_get("id")?, + user_id: row.try_get::<_, uuid::Uuid>("user_id")?.into(), + date_utc: row.try_get("date_utc")?, + category: row.try_get("category")?, + coins: row.try_get("coins")?, + first_detected_at: map_ts( + row.try_get::<_, Option>>("first_detected_at")?, + ), + last_detected_at: map_ts( + row.try_get::<_, Option>>("last_detected_at")?, + ), + claimable_since: map_ts( + row.try_get::<_, Option>>("claimable_since")?, + ), + claimed_at: map_ts(row.try_get::<_, Option>>("claimed_at")?), + activity_sample: row + .try_get::<_, Option>>("activity_sample")? + .map(|json| json.0), + created_at: row + .try_get::<_, DateTime>("created_at")? + .with_timezone(&Utc), + updated_at: row + .try_get::<_, DateTime>("updated_at")? + .with_timezone(&Utc), + }) + } + + async fn get_entry( + txn: &mut PostgresTransaction, + user_id: UserId, + date_utc: NaiveDate, + category: DailyRewardCategory, + ) -> anyhow::Result> { + let row = txn + .txn() + .query_opt( + r#" +select * +from daily_reward_entries +where user_id = $1 + and date_utc = $2 + and category = $3::daily_reward_category +"#, + &[&*user_id, &date_utc, &category], + ) + .await?; + + row.map(Self::map_entry).transpose() + } +} + +impl DailyRewardRepository for PostgresDailyRewardRepository { + #[trace_instrument(skip(self, txn))] + async fn list_by_user_and_date( + &self, + txn: &mut PostgresTransaction, + user_id: UserId, + date_utc: NaiveDate, + ) -> anyhow::Result> { + let rows = txn + .txn() + .query( + r#" +select * +from daily_reward_entries +where user_id = $1 + and date_utc = $2 +order by category asc +"#, + &[&*user_id, &date_utc], + ) + .await?; + + rows.into_iter().map(Self::map_entry).collect() + } + + #[trace_instrument(skip(self, txn))] + async fn upsert_entry( + &self, + txn: &mut PostgresTransaction, + params: DailyRewardEntryUpsert, + ) -> anyhow::Result { + let row = txn + .txn() + .query_one( + r#" +insert into daily_reward_entries (id, user_id, date_utc, category, coins) +values ($1, $2, $3, $4::daily_reward_category, $5) +on conflict (user_id, date_utc, category) +do update set coins = excluded.coins, updated_at = now() +returning * +"#, + &[ + ¶ms.id, + &*params.user_id, + ¶ms.date_utc, + ¶ms.category, + ¶ms.coins, + ], + ) + .await?; + + Self::map_entry(row) + } + + #[trace_instrument(skip(self, txn))] + async fn mark_ready( + &self, + txn: &mut PostgresTransaction, + params: DailyRewardMarkReady, + ) -> anyhow::Result { + let activity_sample: Option> = params.activity_sample.as_ref().map(Json); + let row = txn + .txn() + .query_one( + r#" +update daily_reward_entries + set first_detected_at = coalesce(first_detected_at, $4::timestamptz), + last_detected_at = case + when $5::timestamptz is null then last_detected_at + when last_detected_at is null then $5::timestamptz + else greatest(last_detected_at, $5::timestamptz) + end, + claimable_since = coalesce(claimable_since, $6::timestamptz), + activity_sample = coalesce($7::jsonb, activity_sample), + updated_at = now() +where user_id = $1 + and date_utc = $2 + and category = $3::daily_reward_category +returning * +"#, + &[ + &*params.user_id, + ¶ms.date_utc, + ¶ms.category, + ¶ms.first_detected_at, + ¶ms.last_detected_at, + ¶ms.claimable_since, + &activity_sample, + ], + ) + .await?; + + Self::map_entry(row) + } + + #[trace_instrument(skip(self, txn))] + async fn mark_claimed( + &self, + txn: &mut PostgresTransaction, + params: DailyRewardMarkClaimed, + ) -> Result { + let row = txn + .txn() + .query_opt( + r#" +update daily_reward_entries + set claimed_at = $4, + updated_at = $4 + where user_id = $1 + and date_utc = $2 + and category = $3::daily_reward_category + and claimable_since is not null + and claimed_at is null +returning * +"#, + &[ + &*params.user_id, + ¶ms.date_utc, + ¶ms.category, + ¶ms.claimed_at, + ], + ) + .await + .map_err(|err| DailyRewardMarkClaimedError::Other(err.into()))?; + + if let Some(row) = row { + return Self::map_entry(row).map_err(DailyRewardMarkClaimedError::Other); + } + + let existing = Self::get_entry(txn, params.user_id, params.date_utc, params.category) + .await + .map_err(DailyRewardMarkClaimedError::Other)?; + + match existing { + None => Err(DailyRewardMarkClaimedError::NotFound), + Some(entry) => { + if entry.claimable_since.is_none() { + Err(DailyRewardMarkClaimedError::NotReady) + } else if entry.claimed_at.is_some() { + Err(DailyRewardMarkClaimedError::AlreadyClaimed) + } else { + Err(DailyRewardMarkClaimedError::Other(anyhow!( + "Failed to claim reward due to unknown row state" + ))) + } + } + } + } +} diff --git a/academy_persistence/postgres/src/lib.rs b/academy_persistence/postgres/src/lib.rs index 666a4137..3827b503 100644 --- a/academy_persistence/postgres/src/lib.rs +++ b/academy_persistence/postgres/src/lib.rs @@ -11,8 +11,8 @@ use bb8_postgres::{ }; use ouroboros::self_referencing; use tracing::trace; - pub mod coin; +pub mod daily_rewards; pub mod heart; pub mod mfa; pub mod oauth2; @@ -20,7 +20,6 @@ pub mod paypal; pub mod premium; pub mod session; pub mod user; - type PgClient = tokio_postgres::Client; type PgPooledConnection = PooledConnection<'static, PostgresConnectionManager>; type PgTransaction<'a> = tokio_postgres::Transaction<'a>; diff --git a/config.dev.toml b/config.dev.toml index f27bc748..5cf75b9e 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -15,6 +15,26 @@ from = "Bootstrap Academy DEV " [jwt] secret = "changeme" +[daily_rewards] +enable = true +cache_ttl = "5m" + +[daily_rewards.coins] +arrival = 20 +lecture = 20 +practice = 10 +lab = 30 + +[daily_rewards.activity_sources.skills] +dsn = "postgres://academy@127.0.0.1:5432/academy-skills" + +[daily_rewards.activity_sources.challenges] +dsn = "postgres://academy@127.0.0.1:5432/academy-challenges" + +[daily_rewards.recommendations.skills] +base_url = "http://127.0.0.1:8001/" +timeout = "5s" + [session] access_token_ttl = "1d" diff --git a/config.toml b/config.toml index 6e08226e..03175700 100644 --- a/config.toml +++ b/config.toml @@ -28,6 +28,20 @@ max_lifetime = "30m" # secret = "" download_token_ttl = "10m" +[daily_rewards] +enable = true +cache_ttl = "5m" + +[daily_rewards.coins] +arrival = 20 +lecture = 20 +practice = 10 +lab = 30 + +[daily_rewards.recommendations.skills] +base_url = "http://127.0.0.1:8001/" +timeout = "5s" + [internal] jwt_ttl = "10s"