From d9961318902cd875ac5f78d23a05cb6adeb749fe Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Mon, 6 Apr 2026 18:23:04 +0200 Subject: [PATCH] feat: add activity logs audit endpoint, payment notifications, gateway routing - Add activity_logs handler with paginated admin API - Register /api/admin/activity-logs route in users service - Add gateway routing for activity-logs to users service - Trigger notification on successful tracecoin purchase - Update handlers mod to include activity_logs module --- apps/gateway/src/main.rs | 1 + apps/payments/src/main.rs | 20 +++- apps/users/src/handlers/activity_logs.rs | 134 +++++++++++++++++++++++ apps/users/src/handlers/mod.rs | 1 + apps/users/src/main.rs | 2 + 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 apps/users/src/handlers/activity_logs.rs diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 1600a63..15f6924 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -97,6 +97,7 @@ impl Services { || path.starts_with("/api/admin/discounts") || path.starts_with("/api/admin/tracecoin-packages") || path.starts_with("/api/admin/reports") + || path.starts_with("/api/admin/activity-logs") { Some(self.users_url.clone()) } diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 05eacad..bb7ad66 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -231,8 +231,24 @@ async fn verify_payment( .await .ok(); } - _ => {} - } + _ => {} + } + + // Send notification to user about successful purchase + let _ = sqlx::query!( + r#" + INSERT INTO notifications (user_id, title, body, type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "#, + payment.user_id, + "Tracecoins Purchased Successfully", + format!("Your {} Tracecoin package has been credited to your wallet.", payment.tracecoins_credited), + "PAYMENT", + payment.id + ) + .execute(&state.pool) + .await + .ok(); Ok(Json(VerifyPaymentResponse { verified: true, diff --git a/apps/users/src/handlers/activity_logs.rs b/apps/users/src/handlers/activity_logs.rs new file mode 100644 index 0000000..f203587 --- /dev/null +++ b/apps/users/src/handlers/activity_logs.rs @@ -0,0 +1,134 @@ +use crate::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use chrono::{DateTime, Utc}; +use contracts::auth_middleware::AuthUser; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new().route("/", get(list_activity_logs)) +} + +#[derive(Deserialize)] +struct ListQuery { + actor_id: Option, + entity_type: Option, + action: Option, + from: Option>, + to: Option>, + page: Option, + limit: Option, +} + +#[derive(Serialize, Deserialize, sqlx::FromRow)] +struct ActivityLogDto { + id: Uuid, + actor_id: Uuid, + actor_type: String, + entity_id: Uuid, + entity_type: String, + action: String, + metadata: Option, + ip_address: Option, + user_agent: Option, + created_at: String, +} + +#[derive(Serialize)] +struct PaginatedResponse { + data: Vec, + total: i64, + page: i64, + total_pages: i64, +} + +async fn list_activity_logs( + auth: AuthUser, + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + // Ensure admin permission (require_admin will be applied by router if nested under /api/admin) + let page = params.page.unwrap_or(1).max(1); + let limit = params.limit.unwrap_or(50).clamp(1, 100); + let offset = (page - 1) * limit; + + let actor_id = params.actor_id; + let entity_type = params.entity_type.as_deref().unwrap_or_default(); + let action = params.action.as_deref().unwrap_or_default(); + let from_dt = params.from; + let to_dt = params.to; + + let total: i64 = sqlx::query_scalar!( + r#" + SELECT COUNT(*) FROM activity_logs + WHERE ($1::uuid IS NULL OR actor_id = $1) + AND ($2::text IS NULL OR entity_type = $2) + AND ($3::text IS NULL OR action = $3) + AND ($4::timestamptz IS NULL OR created_at >= $4) + AND ($5::timestamptz IS NULL OR created_at <= $5) + "#, + actor_id, + if entity_type.is_empty() { None } else { Some(entity_type) }, + if action.is_empty() { None } else { Some(action) }, + from_dt, + to_dt, + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let rows = sqlx::query_as::<_, ActivityLogDto>( + r#" + SELECT id, actor_id, actor_type, entity_id, entity_type, action, metadata, ip_address, user_agent, created_at + FROM activity_logs + WHERE ($1::uuid IS NULL OR actor_id = $1) + AND ($2::text IS NULL OR entity_type = $2) + AND ($3::text IS NULL OR action = $3) + AND ($4::timestamptz IS NULL OR created_at >= $4) + AND ($5::timestamptz IS NULL OR created_at <= $5) + ORDER BY created_at DESC + LIMIT $6 OFFSET $7 + "# + ) + .bind(actor_id) + .bind(if entity_type.is_empty() { None } else { Some(entity_type) }) + .bind(if action.is_empty() { None } else { Some(action) }) + .bind(from_dt) + .bind(to_dt) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.pool) + .await; + + match rows { + Ok(data) => { + let total_pages = if total == 0 { 1 } else { (total + limit - 1) / limit }; + ( + StatusCode::OK, + Json(PaginatedResponse { + data, + total, + page, + total_pages, + }), + ) + .into_response() + } + Err(e) => { + tracing::error!("Failed to fetch activity logs: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to fetch activity logs" })), + ) + .into_response() + } + } +} diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index a3bae90..3fbb9b6 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod activity_logs; pub mod approvals; pub mod auth; pub mod config; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 71d465e..00bcf1f 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -77,6 +77,8 @@ async fn main() { .nest("/api/admin/dashboard-config", handlers::config::dashboard_router()) .nest("/api/admin/dashboard", handlers::dashboard::router()) .nest("/api/admin/runtime-configs", handlers::config::admin_runtime_router()) + // ── Admin: Activity Logs (Audit) ─────────────────────────────────── + .nest("/api/admin/activity-logs", handlers::activity_logs::router()) // ── Public Config ───────────────────────────────────────────────── .nest("/api/config/onboarding", handlers::config::onboarding_router()) .nest("/api/config/dashboard", handlers::config::dashboard_router())