nxtgauge-backend-rust/apps/payments/src/packages.rs

418 lines
14 KiB
Rust

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<String>,
pub applicable_role: Option<String>,
pub active_only: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub search: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePackageRequest {
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePackageRequest {
pub name: Option<String>,
pub description: Option<String>,
pub tracecoins_amount: Option<i32>,
pub price: Option<i32>,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, FromRow)]
pub struct PricingPackageRow {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct PricingPackageResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub is_available: bool,
pub is_expired: bool,
}
impl From<PricingPackageRow> 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<AppState> {
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<AppState>,
Query(q): Query<PaginationQuery>,
) -> 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<PricingPackageResponse> = 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<AppState>,
Path(id): Path<Uuid>,
) -> 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<AppState>,
Json(payload): Json<CreatePackageRequest>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePackageRequest>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
) -> 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<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> 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<PricingPackageResponse> = 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<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> 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<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"applicable_role": applicable_role
}))).into_response()
}