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
This commit is contained in:
Ashwin Kumar 2026-04-06 18:23:04 +02:00
parent ab25f7a994
commit d996131890
5 changed files with 156 additions and 2 deletions

View file

@ -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())
}

View file

@ -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,

View file

@ -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<AppState> {
Router::new().route("/", get(list_activity_logs))
}
#[derive(Deserialize)]
struct ListQuery {
actor_id: Option<Uuid>,
entity_type: Option<String>,
action: Option<String>,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
page: Option<i64>,
limit: Option<i64>,
}
#[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<serde_json::Value>,
ip_address: Option<String>,
user_agent: Option<String>,
created_at: String,
}
#[derive(Serialize)]
struct PaginatedResponse {
data: Vec<ActivityLogDto>,
total: i64,
page: i64,
total_pages: i64,
}
async fn list_activity_logs(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> 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()
}
}
}

View file

@ -1,4 +1,5 @@
pub mod admin;
pub mod activity_logs;
pub mod approvals;
pub mod auth;
pub mod config;

View file

@ -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())