446 lines
15 KiB
Rust
446 lines
15 KiB
Rust
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<AppState> {
|
|
Router::new().route("/", get(public_list_packages))
|
|
}
|
|
|
|
/// Admin CRUD
|
|
pub fn packages_router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(list_packages).post(create_package))
|
|
.route("/{id}", patch(update_package).delete(delete_package))
|
|
}
|
|
|
|
pub fn reports_router() -> Router<AppState> {
|
|
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<String>,
|
|
is_active: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreatePackageBody {
|
|
name: String,
|
|
// Accept either role_key or role
|
|
role_key: Option<String>,
|
|
role: Option<String>,
|
|
package_type: Option<String>,
|
|
// Accept either tracecoins_amount or tracecoin_amount
|
|
tracecoins_amount: Option<i32>,
|
|
tracecoin_amount: Option<i32>,
|
|
price_inr: i32,
|
|
description: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PatchPackageBody {
|
|
name: Option<String>,
|
|
role_key: Option<String>,
|
|
role: Option<String>,
|
|
package_type: Option<String>,
|
|
tracecoins_amount: Option<i32>,
|
|
tracecoin_amount: Option<i32>,
|
|
price_inr: Option<i32>,
|
|
description: Option<String>,
|
|
is_active: Option<bool>,
|
|
}
|
|
|
|
// ── Report types ──────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct DateRangeQuery {
|
|
from: Option<String>,
|
|
to: Option<String>,
|
|
}
|
|
|
|
// ── 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<String>,
|
|
is_active: bool,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ExistingPackageRow {
|
|
name: String,
|
|
role_key: String,
|
|
package_type: String,
|
|
tracecoins_amount: i32,
|
|
price_inr: i32,
|
|
description: Option<String>,
|
|
is_active: bool,
|
|
}
|
|
|
|
// ── Package handlers ──────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct PackageQuery {
|
|
role: Option<String>,
|
|
}
|
|
|
|
async fn public_list_packages(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<PackageQuery>,
|
|
) -> 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<PackageDto> = 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<AppState>,
|
|
) -> 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<PackageDto> = 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<AppState>,
|
|
Json(body): Json<CreatePackageBody>,
|
|
) -> 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<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<PatchPackageBody>,
|
|
) -> 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<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> 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<AppState>,
|
|
Query(params): Query<DateRangeQuery>,
|
|
) -> 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<AppState>,
|
|
Query(params): Query<DateRangeQuery>,
|
|
) -> 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()
|
|
}
|