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 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 applicable_roles: Vec, pub tracecoins_amount: i32, pub price: i32, pub duration_days: Option, pub valid_from: Option>, pub valid_until: Option>, pub is_promotional: Option, pub is_active: Option, pub features: Option, } #[derive(Debug, Deserialize)] pub struct UpdatePackageRequest { pub name: Option, pub description: Option, pub tracecoins_amount: Option, pub price: Option, pub duration_days: Option, pub valid_from: Option>, pub valid_until: Option>, pub is_promotional: Option, pub is_active: Option, pub features: Option, } #[derive(Debug, FromRow)] pub struct PricingPackageRow { pub id: Uuid, pub name: String, pub description: Option, pub package_type: String, pub applicable_roles: Vec, pub tracecoins_amount: i32, pub price: 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, } #[derive(Debug, Serialize)] pub struct PricingPackageResponse { pub id: Uuid, pub name: String, pub description: Option, pub package_type: String, pub applicable_roles: Vec, pub tracecoins_amount: i32, pub price: 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 { let now = chrono::Utc::now(); let is_expired = row.valid_until.map(|v| v < now).unwrap_or(false); let is_not_started = row.valid_from.map(|v| v > now).unwrap_or(false); let is_available = row.is_active && !is_expired && !is_not_started; PricingPackageResponse { id: row.id, name: row.name, description: row.description, package_type: row.package_type, applicable_roles: row.applicable_roles, tracecoins_amount: row.tracecoins_amount, price: row.price, duration_days: row.duration_days, valid_from: row.valid_from, valid_until: row.valid_until, is_promotional: row.is_promotional, is_active: row.is_active, features: row.features, created_at: row.created_at, updated_at: row.updated_at, is_available, is_expired, } } } 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); let limit = q.limit.unwrap_or(20).min(100); let offset = (page - 1) * limit; let search_filter = q.search .as_ref() .map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''"))) .unwrap_or_default(); let packages = sqlx::query_as::<_, PricingPackageRow>( &format!( r#" SELECT id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at FROM pricing_packages WHERE 1=1 {} ORDER BY created_at DESC LIMIT {} OFFSET {} "#, search_filter, limit, offset ) ) .fetch_all(&state.pool) .await; let packages = match packages { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let total: (i64,) = match sqlx::query_as( &format!( "SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}", search_filter ) ) .fetch_one(&state.pool) .await { Ok(t) => t, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = packages.into_iter().map(|p| p.into()).collect(); (StatusCode::OK, Json(serde_json::json!({ "data": packages, "pagination": { "page": page, "limit": limit, "total": total.0, "pages": (total.0 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>( r#" SELECT id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at FROM pricing_packages WHERE id = $1 "# ) .bind(id) .fetch_optional(&state.pool) .await { Ok(Some(pkg)) => { let response: PricingPackageResponse = pkg.into(); (StatusCode::OK, Json(response)).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 result = sqlx::query_as::<_, PricingPackageRow>( r#" INSERT INTO pricing_packages (name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at "# ) .bind(&payload.name) .bind(&payload.description) .bind(&payload.package_type) .bind(&payload.applicable_roles) .bind(payload.tracecoins_amount) .bind(payload.price) .bind(payload.duration_days) .bind(payload.valid_from) .bind(payload.valid_until) .bind(payload.is_promotional.unwrap_or(false)) .bind(payload.is_active.unwrap_or(true)) .bind(payload.features) .fetch_one(&state.pool) .await; match result { Ok(pkg) => { let response: PricingPackageResponse = pkg.into(); (StatusCode::CREATED, Json(response)).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 existing = sqlx::query_as::<_, PricingPackageRow>( "SELECT * FROM pricing_packages WHERE id = $1" ) .bind(id) .fetch_optional(&state.pool) .await; let existing = match existing { Ok(Some(e)) => e, Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let updated = sqlx::query_as::<_, PricingPackageRow>( r#" UPDATE pricing_packages SET name = COALESCE($2, name), description = COALESCE($3, description), tracecoins_amount = COALESCE($4, tracecoins_amount), price = COALESCE($5, price), duration_days = COALESCE($6, duration_days), valid_from = COALESCE($7, valid_from), valid_until = COALESCE($8, valid_until), is_promotional = COALESCE($9, is_promotional), is_active = COALESCE($10, is_active), features = COALESCE($11, features), updated_at = NOW() WHERE id = $1 RETURNING id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at "# ) .bind(id) .bind(&payload.name) .bind(&payload.description) .bind(payload.tracecoins_amount) .bind(payload.price) .bind(payload.duration_days) .bind(payload.valid_from) .bind(payload.valid_until) .bind(payload.is_promotional) .bind(payload.is_active) .bind(payload.features) .fetch_one(&state.pool) .await; match updated { Ok(pkg) => { let response: PricingPackageResponse = pkg.into(); (StatusCode::OK, Json(response)).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(r) if r.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.as_deref().unwrap_or("TRACECOIN_BUNDLE"); let now = chrono::Utc::now(); let packages = sqlx::query_as::<_, PricingPackageRow>( r#" SELECT id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at FROM pricing_packages WHERE package_type = $1 AND is_active = true AND (valid_from IS NULL OR valid_from <= $2) AND (valid_until IS NULL OR valid_until > $2) ORDER BY is_promotional DESC, price ASC "# ) .bind(package_type) .bind(now) .fetch_all(&state.pool) .await; let packages = match packages { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = packages.into_iter().map(|p| p.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 applicable_role = q.applicable_role.as_deref().unwrap_or(""); let active_only = q.active_only.unwrap_or(true); let now = chrono::Utc::now(); let packages = sqlx::query_as::<_, PricingPackageRow>( &format!( r#" SELECT id, name, description, package_type, applicable_roles, tracecoins_amount, price, duration_days, valid_from, valid_until, is_promotional, is_active, features, created_at, updated_at FROM pricing_packages WHERE ($1 = '' OR $1 = ANY(applicable_roles)) AND (is_active = true OR {} = false) AND (valid_from IS NULL OR valid_from <= $2) AND (valid_until IS NULL OR valid_until > $2) ORDER BY is_promotional DESC, price ASC "#, if active_only { "true" } else { "false" } ) ) .bind(applicable_role) .bind(now) .fetch_all(&state.pool) .await; let packages = match packages { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let packages: Vec = packages.into_iter().map(|p| p.into()).collect(); (StatusCode::OK, Json(serde_json::json!({ "data": packages, "applicable_role": applicable_role }))).into_response() }