use crate::AppState; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, patch, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; #[derive(Debug, Deserialize)] pub struct PackageTypeQuery { pub package_type: Option, pub applicable_role: Option, pub role: Option, pub active_only: Option, } #[derive(Debug, Deserialize)] pub struct PaginationQuery { pub page: Option, pub limit: Option, pub search: Option, } #[derive(Debug, Deserialize)] pub struct CreatePackageRequest { pub name: String, pub description: Option, pub package_type: String, pub role_key: Option, pub applicable_roles: Option>, pub tracecoins_amount: i32, pub price: Option, pub price_inr: Option, pub is_active: Option, } #[derive(Debug, Deserialize)] pub struct UpdatePackageRequest { pub name: Option, pub description: Option, pub role_key: Option, pub applicable_roles: Option>, pub tracecoins_amount: Option, pub price: Option, pub price_inr: Option, pub is_active: Option, } #[derive(Debug, FromRow)] pub struct PricingPackageRow { pub id: Uuid, pub name: String, pub role_key: String, pub package_type: String, pub tracecoins_amount: i32, pub price_inr: i32, pub description: Option, pub is_active: bool, pub created_at: chrono::DateTime, } #[derive(Debug, Serialize)] pub struct PricingPackageResponse { pub id: Uuid, pub name: String, pub description: Option, pub role_key: String, pub applicable_roles: Vec, pub package_type: String, pub tracecoins_amount: i32, pub price: i32, pub price_inr: i32, pub duration_days: Option, pub valid_from: Option>, pub valid_until: Option>, pub is_promotional: bool, pub is_active: bool, pub features: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub is_available: bool, pub is_expired: bool, } impl From for PricingPackageResponse { fn from(row: PricingPackageRow) -> Self { Self { id: row.id, name: row.name, description: row.description, role_key: row.role_key.clone(), applicable_roles: vec![row.role_key], package_type: row.package_type, tracecoins_amount: row.tracecoins_amount, price: row.price_inr, price_inr: row.price_inr, duration_days: None, valid_from: None, valid_until: None, is_promotional: false, is_active: row.is_active, features: None, created_at: row.created_at, updated_at: row.created_at, is_available: row.is_active, is_expired: false, } } } fn normalize_role_key(role_key: Option, applicable_roles: Option>) -> Result { if let Some(role) = role_key { let cleaned = role.trim().to_uppercase(); if !cleaned.is_empty() { return Ok(cleaned); } } if let Some(roles) = applicable_roles { if let Some(role) = roles.into_iter().map(|role| role.trim().to_uppercase()).find(|role| !role.is_empty()) { return Ok(role); } } Err("role_key is required".to_string()) } fn package_query(base_where: &str, order_by: &str) -> String { format!( r#" SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at FROM pricing_packages {base_where} {order_by} "# ) } pub fn router() -> Router { Router::new() .route("/", get(list_packages)) .route("/", post(create_package)) .route("/{id}", get(get_package)) .route("/{id}", patch(update_package)) .route("/{id}", delete(delete_package)) .route("/by-type", get(get_packages_by_type)) .route("/for-role", get(get_packages_for_role)) } async fn list_packages( State(state): State, Query(q): Query, ) -> impl IntoResponse { let page = q.page.unwrap_or(1).max(1); let limit = q.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; let search = q.search.unwrap_or_default().trim().to_string(); let rows = sqlx::query_as::<_, PricingPackageRow>( &format!( "{} LIMIT $2 OFFSET $3", package_query( "WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')", "ORDER BY created_at DESC" ) ), ) .bind(&search) .bind(limit) .bind(offset) .fetch_all(&state.pool) .await; let rows = match rows { Ok(rows) => rows, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let total: i64 = match sqlx::query_scalar( "SELECT COUNT(*) FROM pricing_packages WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')", ) .bind(&search) .fetch_one(&state.pool) .await { Ok(total) => total, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = rows.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(serde_json::json!({ "data": packages, "packages": packages, "pagination": { "page": page, "limit": limit, "total": total, "pages": (total as f64 / limit as f64).ceil() as i64 } }))) .into_response() } async fn get_package( State(state): State, Path(id): Path, ) -> impl IntoResponse { match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", "")) .bind(id) .fetch_optional(&state.pool) .await { Ok(Some(pkg)) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(), Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn create_package( State(state): State, Json(payload): Json, ) -> impl IntoResponse { let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) { Ok(role_key) => role_key, Err(message) => return (StatusCode::BAD_REQUEST, message).into_response(), }; let price_inr = payload.price_inr.or(payload.price).unwrap_or(0); let result = sqlx::query_as::<_, PricingPackageRow>( r#" INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at "#, ) .bind(&payload.name) .bind(&role_key) .bind(&payload.package_type) .bind(payload.tracecoins_amount) .bind(price_inr) .bind(&payload.description) .bind(payload.is_active.unwrap_or(true)) .fetch_one(&state.pool) .await; match result { Ok(pkg) => (StatusCode::CREATED, Json(PricingPackageResponse::from(pkg))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn update_package( State(state): State, Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", "")) .bind(id) .fetch_optional(&state.pool) .await { Ok(Some(pkg)) => pkg, Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) { Ok(role_key) => role_key, Err(_) => current.role_key.clone(), }; let price_inr = payload.price_inr.or(payload.price).unwrap_or(current.price_inr); let updated = sqlx::query_as::<_, PricingPackageRow>( r#" UPDATE pricing_packages SET name = COALESCE($2, name), role_key = $3, description = COALESCE($4, description), tracecoins_amount = COALESCE($5, tracecoins_amount), price_inr = $6, is_active = COALESCE($7, is_active) WHERE id = $1 RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at "#, ) .bind(id) .bind(&payload.name) .bind(&role_key) .bind(&payload.description) .bind(payload.tracecoins_amount) .bind(price_inr) .bind(payload.is_active) .fetch_one(&state.pool) .await; match updated { Ok(pkg) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn delete_package( State(state): State, Path(id): Path, ) -> impl IntoResponse { match sqlx::query("DELETE FROM pricing_packages WHERE id = $1") .bind(id) .execute(&state.pool) .await { Ok(result) if result.rows_affected() > 0 => { (StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response() } Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn get_packages_by_type( State(state): State, Query(q): Query, ) -> impl IntoResponse { let package_type = q.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string()); let rows = sqlx::query_as::<_, PricingPackageRow>( &package_query( "WHERE package_type = $1 AND is_active = true", "ORDER BY price_inr ASC, created_at DESC", ), ) .bind(&package_type) .fetch_all(&state.pool) .await; let rows = match rows { Ok(rows) => rows, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = rows.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(serde_json::json!({ "data": packages, "package_type": package_type }))) .into_response() } async fn get_packages_for_role( State(state): State, Query(q): Query, ) -> impl IntoResponse { let role = q .applicable_role .or(q.role) .unwrap_or_default() .trim() .to_uppercase(); let active_only = q.active_only.unwrap_or(true); let rows = sqlx::query_as::<_, PricingPackageRow>( &package_query( "WHERE ($1 = '' OR role_key = $1) AND ($2 = false OR is_active = true)", "ORDER BY price_inr ASC, created_at DESC", ), ) .bind(&role) .bind(active_only) .fetch_all(&state.pool) .await; let rows = match rows { Ok(rows) => rows, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = rows.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(serde_json::json!({ "data": packages, "applicable_role": role }))) .into_response() }