use crate::AppState; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router, }; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use uuid::Uuid; // ── Routers ─────────────────────────────────────────────────────────────────── /// Public (user-facing) — list active packages for their role pub fn public_packages_router() -> Router { Router::new().route("/", get(public_list_packages)) } /// Admin CRUD pub fn packages_router() -> Router { Router::new() .route("/", get(list_packages).post(create_package)) .route("/{id}", patch(update_package).delete(delete_package)) } pub fn reports_router() -> Router { Router::new() .route("/users", get(report_users)) .route("/revenue", get(report_revenue)) } // ── Package DTOs ────────────────────────────────────────────────────────────── #[derive(Serialize)] struct PackageDto { id: Uuid, name: String, role_key: String, // Also expose as 'role' for admin UI compatibility role: String, package_type: String, tracecoins_amount: i32, // Also expose as 'tracecoin_amount' for admin UI compatibility tracecoin_amount: i32, price_inr: i32, description: Option, is_active: bool, } #[derive(Deserialize)] struct CreatePackageBody { name: String, // Accept either role_key or role role_key: Option, role: Option, package_type: Option, // Accept either tracecoins_amount or tracecoin_amount tracecoins_amount: Option, tracecoin_amount: Option, price_inr: i32, description: Option, } #[derive(Deserialize)] struct PatchPackageBody { name: Option, role_key: Option, role: Option, package_type: Option, tracecoins_amount: Option, tracecoin_amount: Option, price_inr: Option, description: Option, is_active: Option, } // ── Report types ────────────────────────────────────────────────────────────── #[derive(Deserialize)] struct DateRangeQuery { from: Option, to: Option, } // ── FromRow structs ────────────────────────────────────────────────────────── #[derive(sqlx::FromRow)] struct PackageRow { id: Uuid, name: String, role_key: String, package_type: String, tracecoins_amount: i32, price_inr: i32, description: Option, is_active: bool, } #[derive(sqlx::FromRow)] struct ExistingPackageRow { name: String, role_key: String, package_type: String, tracecoins_amount: i32, price_inr: i32, description: Option, is_active: bool, } // ── Package handlers ────────────────────────────────────────────────────────── #[derive(Deserialize)] struct PackageQuery { role: Option, } async fn public_list_packages( State(state): State, Query(params): Query, ) -> impl IntoResponse { let rows = sqlx::query_as::<_, PackageRow>( r#" SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE is_active = true AND ($1::text IS NULL OR role_key = $1 OR role_key = 'ALL') ORDER BY role_key, price_inr "#, ) .bind(params.role) .fetch_all(&state.pool) .await; match rows { Ok(rows) => { let dtos: Vec = rows .into_iter() .map(|r| PackageDto { id: r.id, name: r.name.clone(), role_key: r.role_key.clone(), role: r.role_key.clone(), package_type: r.package_type, tracecoins_amount: r.tracecoins_amount, tracecoin_amount: r.tracecoins_amount, price_inr: r.price_inr, description: r.description, is_active: r.is_active, }) .collect(); (StatusCode::OK, Json(serde_json::json!({ "packages": dtos }))).into_response() } Err(e) => { tracing::error!("Failed to list packages: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load packages" }))).into_response() } } } async fn list_packages( _auth: AuthUser, State(state): State, ) -> impl IntoResponse { let rows = sqlx::query_as::<_, PackageRow>( r#" SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages ORDER BY role_key, price_inr "#, ) .fetch_all(&state.pool) .await; match rows { Ok(rows) => { let dtos: Vec = rows .into_iter() .map(|r| PackageDto { id: r.id, name: r.name.clone(), role_key: r.role_key.clone(), role: r.role_key.clone(), package_type: r.package_type, tracecoins_amount: r.tracecoins_amount, tracecoin_amount: r.tracecoins_amount, price_inr: r.price_inr, description: r.description, is_active: r.is_active, }) .collect(); (StatusCode::OK, Json(serde_json::json!({ "packages": dtos }))).into_response() } Err(e) => { tracing::error!("Failed to list packages: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load packages" }))).into_response() } } } async fn create_package( _auth: AuthUser, State(state): State, Json(body): Json, ) -> impl IntoResponse { let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string()); // Accept tracecoin_amount (admin UI) or tracecoins_amount let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(0); // Accept role (admin UI) or role_key let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string()); let row = sqlx::query_as::<_, PackageRow>( r#" INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active "#, ) .bind(&body.name) .bind(&role_key) .bind(&package_type) .bind(tracecoins_amount) .bind(body.price_inr) .bind(&body.description) .fetch_one(&state.pool) .await; match row { Ok(r) => { let dto = PackageDto { id: r.id, name: r.name.clone(), role_key: r.role_key.clone(), role: r.role_key.clone(), package_type: r.package_type, tracecoins_amount: r.tracecoins_amount, tracecoin_amount: r.tracecoins_amount, price_inr: r.price_inr, description: r.description, is_active: r.is_active, }; (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() } Err(e) => { tracing::error!("Failed to create package: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create package" }))).into_response() } } } async fn update_package( _auth: AuthUser, State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let existing = sqlx::query_as::<_, ExistingPackageRow>( "SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1", ) .bind(id) .fetch_optional(&state.pool) .await; let existing = match existing { Ok(Some(r)) => r, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Package not found" }))).into_response(), Err(e) => { tracing::error!("DB error: {e}"); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "DB error" }))).into_response(); } }; let name = body.name.unwrap_or(existing.name); let role_key = body.role_key.or(body.role).unwrap_or(existing.role_key); let package_type = body.package_type.unwrap_or(existing.package_type); let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(existing.tracecoins_amount); let price_inr = body.price_inr.unwrap_or(existing.price_inr); let description = body.description.or(existing.description); let is_active = body.is_active.unwrap_or(existing.is_active); let result = sqlx::query( r#" UPDATE pricing_packages SET name = $1, role_key = $2, package_type = $3, tracecoins_amount = $4, price_inr = $5, description = $6, is_active = $7 WHERE id = $8 "#, ) .bind(name) .bind(role_key) .bind(package_type) .bind(tracecoins_amount) .bind(price_inr) .bind(description) .bind(is_active) .bind(id) .execute(&state.pool) .await; match result { Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Err(e) => { tracing::error!("Failed to update package {id}: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update package" }))).into_response() } } } async fn delete_package( _auth: AuthUser, State(state): State, Path(id): Path, ) -> impl IntoResponse { let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1") .bind(id) .execute(&state.pool) .await; match result { Ok(r) if r.rows_affected() == 0 => { (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Package not found" }))).into_response() } Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { tracing::error!("Failed to deactivate package {id}: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to delete package" }))).into_response() } } } // ── Report handlers ─────────────────────────────────────────────────────────── async fn report_users( _auth: AuthUser, State(state): State, Query(params): Query, ) -> impl IntoResponse { let from = params.from.as_deref().unwrap_or("2000-01-01"); let to = params.to.as_deref().unwrap_or("2099-12-31"); let from_ts = match chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d") { Ok(d) => d.and_hms_opt(0, 0, 0).unwrap().and_utc(), Err(_) => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid from date" }))).into_response(); } }; let to_ts = match chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d") { Ok(d) => d.and_hms_opt(23, 59, 59).unwrap().and_utc(), Err(_) => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid to date" }))).into_response(); } }; let total_users: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); let new_users: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND created_at >= NOW() - INTERVAL '30 days'", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); // Active = email-verified users registered in range let active_users: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND email_verified = true", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); (StatusCode::OK, Json(serde_json::json!({ "total_users": total_users, "new_users": new_users, "active_users": active_users, "from": from, "to": to }))).into_response() } async fn report_revenue( _auth: AuthUser, State(state): State, Query(params): Query, ) -> impl IntoResponse { let from = params.from.as_deref().unwrap_or("2000-01-01"); let to = params.to.as_deref().unwrap_or("2099-12-31"); let from_ts = match chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d") { Ok(d) => d.and_hms_opt(0, 0, 0).unwrap().and_utc(), Err(_) => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid from date" }))).into_response(); } }; let to_ts = match chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d") { Ok(d) => d.and_hms_opt(23, 59, 59).unwrap().and_utc(), Err(_) => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid to date" }))).into_response(); } }; let total_revenue: i64 = sqlx::query_scalar::<_, i64>( "SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); let total_orders: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); let total_tracecoins_sold: i64 = sqlx::query_scalar::<_, i64>( "SELECT COALESCE(SUM(tracecoins_credited), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", ) .bind(from_ts) .bind(to_ts) .fetch_one(&state.pool) .await .unwrap_or(0); (StatusCode::OK, Json(serde_json::json!({ "total_revenue": total_revenue, "total_orders": total_orders, "total_tracecoins_sold": total_tracecoins_sold, "from": from, "to": to }))).into_response() }