diff --git a/gov-portal-db/src/mongo_client.rs b/gov-portal-db/src/mongo_client.rs index 5e96634..cea5881 100644 --- a/gov-portal-db/src/mongo_client.rs +++ b/gov-portal-db/src/mongo_client.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use mongodb::{ bson::Document, - options::{ClientOptions, FindOptions, InsertOneOptions, UpdateModifications, UpdateOptions}, + options::{ + ClientOptions, CountOptions, FindOptions, InsertOneOptions, UpdateModifications, + UpdateOptions, + }, results::{InsertOneResult, UpdateResult}, Client, Collection, Cursor, Database, }; @@ -46,6 +49,17 @@ impl MongoCollection { .with_options(find_options) .await } + /// Query count request from collection by filter + pub async fn count( + &self, + filter: impl Into<Document>, + count_options: impl Into<Option<CountOptions>>, + ) -> Result<u64, mongodb::error::Error> { + self.inner + .count_documents(filter.into()) + .with_options(count_options) + .await + } /// Insert single document to collection #[allow(unused)] diff --git a/gov-portal-db/src/rewards_manager/mod.rs b/gov-portal-db/src/rewards_manager/mod.rs index 811ba2e..2e95d24 100644 --- a/gov-portal-db/src/rewards_manager/mod.rs +++ b/gov-portal-db/src/rewards_manager/mod.rs @@ -1,10 +1,10 @@ pub mod client; pub mod error; -use bson::doc; +use bson::{doc, Document}; use ethereum_types::Address; use futures_util::TryStreamExt; -use mongodb::options::{FindOptions, UpdateOptions}; +use mongodb::options::{CountOptions, FindOptions, UpdateOptions}; use serde::Deserialize; use shared::common::{ @@ -104,7 +104,43 @@ impl RewardsManager { } } - /// Searches for multiple user profiles within MongoDB by provided EVM-like address [`Address`] list and returns [`Vec<UserProfile>`] + /// Counts all rewards allocated by requestor within MongoDB by provided wallet EVM-like address [`Address`] + pub async fn count_rewards( + &self, + requestor: &Address, + from: Option<u64>, + to: Option<u64>, + community: Option<&str>, + ) -> Result<u64, error::Error> { + if !self + .config + .moderators + .iter() + .any(|wallet| wallet == requestor) + { + return Err(error::Error::Unauthorized); + } + + let expr = self.build_rewards_filter_expr(from, to, community)?; + let filter = doc! { + "$expr": bson::to_bson(&expr)?, + }; + + let res = tokio::time::timeout( + self.db_client.req_timeout, + self.db_client + .collection() + .count(filter, CountOptions::default()), + ) + .await? + .map_err(error::Error::from); + + tracing::debug!("Count total rewards result: {res:?}"); + + res + } + + /// Searches all rewards allocated by requestor within MongoDB by provided wallet EVM-like address [`Address`] and returns [`Vec<Rewards>`] pub async fn get_rewards( &self, requestor: &Address, @@ -112,7 +148,7 @@ impl RewardsManager { limit: Option<u64>, from: Option<u64>, to: Option<u64>, - community: Option<String>, + community: Option<&str>, ) -> Result<Vec<Rewards>, error::Error> { let start = start.unwrap_or_default(); let limit = limit @@ -128,75 +164,9 @@ impl RewardsManager { return Err(error::Error::Unauthorized); } - let mut in_and_cond_doc = doc! { - "$and": [] - }; - - let mut cond = doc! {}; - - if let Some(community) = community { - cond.insert("$eq", bson::to_bson(&["$$wallet.v.community", &community])?); - - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$gt": [{ "$size": "$$entries" }, 0] - } - .into(), - ); - } - - if from.is_some() || to.is_some() { - if let Some(from) = from { - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$gte": ["$$timestamp", bson::to_bson(&from)?] - } - .into(), - ); - } - - if let Some(to) = to { - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$lte": ["$$timestamp", bson::to_bson(&to)?] - } - .into(), - ); - } - } - + let expr = self.build_rewards_filter_expr(from, to, community)?; let filter = doc! { - "$expr":{ - "$let":{ - "vars":{ - "entries":{ - "$filter":{ - "input":{ - "$objectToArray":"$wallets" - }, - "as":"wallet", - "cond": cond - } - } - }, - "in":{ - "$let": { - "vars": { - "timestamp":{ - "$arrayElemAt":[{ - "$map":{ - "input":"$$entries", - "as":"entry", - "in":"$$entry.v.timestamp" - } - },0] - } - }, - "in": in_and_cond_doc - } - } - } - } + "$expr": expr }; let find_options = FindOptions::builder() @@ -250,7 +220,48 @@ impl RewardsManager { }) } - /// Searches for multiple user profiles within MongoDB by provided EVM-like address [`Address`] list and returns [`Vec<UserProfile>`] + /// Counts rewards allocated for user within MongoDB by provided wallet EVM-like address [`Address`] + pub async fn count_rewards_by_wallet( + &self, + requestor: &Address, + wallet: &Address, + from: Option<u64>, + to: Option<u64>, + community: Option<&str>, + ) -> Result<u64, error::Error> { + let requires_moderator_access_rights = wallet != requestor; + + if requires_moderator_access_rights + && !self + .config + .moderators + .iter() + .any(|wallet| wallet == requestor) + { + return Err(error::Error::Unauthorized); + } + + let expr = self.build_rewards_filter_expr(from, to, community)?; + let filter = doc! { + format!("wallets.0x{}", hex::encode(wallet)): { "$exists": true }, + "$expr": bson::to_bson(&expr)?, + }; + + let res = tokio::time::timeout( + self.db_client.req_timeout, + self.db_client + .collection() + .count(filter, CountOptions::default()), + ) + .await? + .map_err(error::Error::from); + + tracing::debug!("Count total rewards by wallet ({wallet:?}) result: {res:?}"); + + res + } + + /// Searches rewards allocated for user within MongoDB by provided wallet EVM-like address [`Address`] and returns [`Vec<Rewards>`] #[allow(clippy::too_many_arguments)] pub async fn get_rewards_by_wallet( &self, @@ -260,7 +271,7 @@ impl RewardsManager { limit: Option<u64>, from: Option<u64>, to: Option<u64>, - community: Option<String>, + community: Option<&str>, ) -> Result<Vec<Rewards>, error::Error> { let start = start.unwrap_or_default(); let limit = limit @@ -279,76 +290,10 @@ impl RewardsManager { return Err(error::Error::Unauthorized); } - let mut in_and_cond_doc = doc! { - "$and": [] - }; - - let mut cond = doc! {}; - - if let Some(community) = community { - cond.insert("$eq", bson::to_bson(&["$$wallet.v.community", &community])?); - - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$gt": [{ "$size": "$$entries" }, 0] - } - .into(), - ); - } - - if from.is_some() || to.is_some() { - if let Some(from) = from { - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$gte": ["$$timestamp", bson::to_bson(&from)?] - } - .into(), - ); - } - - if let Some(to) = to { - in_and_cond_doc.get_array_mut("$and")?.push( - doc! { - "$lt": ["$$timestamp", bson::to_bson(&to)?] - } - .into(), - ); - } - } - + let expr = self.build_rewards_filter_expr(from, to, community)?; let filter = doc! { format!("wallets.0x{}", hex::encode(wallet)): { "$exists": true }, - "$expr":{ - "$let":{ - "vars":{ - "entries":{ - "$filter":{ - "input":{ - "$objectToArray":"$wallets" - }, - "as":"wallet", - "cond": cond - } - } - }, - "in":{ - "$let": { - "vars": { - "timestamp":{ - "$arrayElemAt":[{ - "$map":{ - "input":"$$entries", - "as":"entry", - "in":"$$entry.v.timestamp" - } - },0] - } - }, - "in": in_and_cond_doc - } - } - } - } + "$expr": bson::to_bson(&expr)?, }; let find_options = FindOptions::builder() @@ -406,4 +351,80 @@ impl RewardsManager { .collect() }) } + + fn build_rewards_filter_expr( + &self, + from: Option<u64>, + to: Option<u64>, + community: Option<&str>, + ) -> Result<Document, error::Error> { + let mut in_and_cond_doc = doc! { + "$and": [] + }; + + let mut cond = doc! {}; + + if let Some(community) = community { + cond.insert("$eq", bson::to_bson(&["$$wallet.v.community", community])?); + + in_and_cond_doc.get_array_mut("$and")?.push( + doc! { + "$gt": [{ "$size": "$$entries" }, 0] + } + .into(), + ); + } + + if from.is_some() || to.is_some() { + if let Some(from) = from { + in_and_cond_doc.get_array_mut("$and")?.push( + doc! { + "$gte": ["$$timestamp", bson::to_bson(&from)?] + } + .into(), + ); + } + + if let Some(to) = to { + in_and_cond_doc.get_array_mut("$and")?.push( + doc! { + "$lt": ["$$timestamp", bson::to_bson(&to)?] + } + .into(), + ); + } + } + + Ok(doc! { + "$let":{ + "vars":{ + "entries":{ + "$filter":{ + "input":{ + "$objectToArray":"$wallets" + }, + "as":"wallet", + "cond": cond + } + } + }, + "in":{ + "$let": { + "vars": { + "timestamp":{ + "$arrayElemAt":[{ + "$map":{ + "input":"$$entries", + "as":"entry", + "in":"$$entry.v.timestamp" + } + },0] + } + }, + "in": in_and_cond_doc + } + } + } + }) + } } diff --git a/gov-portal-db/src/server.rs b/gov-portal-db/src/server.rs index 6849a2a..f4b9209 100644 --- a/gov-portal-db/src/server.rs +++ b/gov-portal-db/src/server.rs @@ -1,7 +1,7 @@ use axum::{extract::State, routing::post, Json, Router}; use chrono::{DateTime, Utc}; use ethereum_types::Address; -use futures_util::{future, FutureExt}; +use futures_util::{future, FutureExt, TryFutureExt}; use jsonwebtoken::TokenData; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::Arc}; @@ -106,6 +106,7 @@ pub struct TokenResponse { /// JSON-serialized request passed as POST-data to `/rewards` endpoint #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct RewardsRequest { pub token: SessionToken, pub wallet: Option<Address>, @@ -116,6 +117,13 @@ pub struct RewardsRequest { pub community: Option<String>, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RewardsResponse { + pub data: Vec<Rewards>, + pub total: u64, +} + /// JSON-serialized request passed as POST-data to `/users` endpoint #[derive(Debug, Deserialize)] pub struct UsersRequest { @@ -566,7 +574,7 @@ async fn update_reward_route( async fn rewards_route( State(state): State<AppState>, Json(req): Json<RewardsRequest>, -) -> Result<Json<Vec<Rewards>>, String> { +) -> Result<Json<RewardsResponse>, String> { tracing::debug!("[/rewards] Request {:?}", req); let res = match state @@ -588,7 +596,18 @@ async fn rewards_route( }, )) => state .rewards_manager - .get_rewards(&requestor, start, limit, from, to, community) + .count_rewards(&requestor, from, to, community.as_deref()) + .and_then(|total| { + let manager = Arc::clone(&state.rewards_manager); + let community = community.as_deref(); + + async move { + manager + .get_rewards(&requestor, start, limit, from, to, community) + .await + .map(|data| RewardsResponse { data, total }) + } + }) .await .map_err(|e| format!("Unable to get rewards. Error: {e}")), @@ -606,10 +625,22 @@ async fn rewards_route( }, )) => state .rewards_manager - .get_rewards_by_wallet(&requestor, &wallet, start, limit, from, to, community) + .count_rewards_by_wallet(&requestor, &wallet, from, to, community.as_deref()) + .and_then(|total| { + let manager = Arc::clone(&state.rewards_manager); + let community = community.as_deref(); + + async move { + manager + .get_rewards_by_wallet( + &requestor, &wallet, start, limit, from, to, community, + ) + .await + .map(|data| RewardsResponse { data, total }) + } + }) .await .map_err(|e| format!("Unable to get rewards. Error: {e}")), - Err(e) => Err(format!("Rewards request failure. Error: {e}")), }; diff --git a/gov-portal-db/tests/test_rewards_manager.rs b/gov-portal-db/tests/test_rewards_manager.rs index b8254ff..5144e2d 100644 --- a/gov-portal-db/tests/test_rewards_manager.rs +++ b/gov-portal-db/tests/test_rewards_manager.rs @@ -247,7 +247,7 @@ async fn test_rewards_endpoint() -> Result<(), anyhow::Error> { None, None, None, - Some("3".into()), + Some("3"), ) .await .unwrap() @@ -490,7 +490,7 @@ async fn test_rewards_by_wallet() -> Result<(), anyhow::Error> { None, Some(now + 2 * 4 * 60 * 60 + 1), None, - Some("0".into()), + Some("0"), ) .await .unwrap() @@ -507,7 +507,7 @@ async fn test_rewards_by_wallet() -> Result<(), anyhow::Error> { None, Some(now + 2 * 4 * 60 * 60 + 1), None, - Some("1".into()), + Some("1"), ) .await .unwrap()