389 lines
12 KiB
Rust
389 lines
12 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 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 role_key: Option<String>,
|
|
pub applicable_roles: Option<Vec<String>>,
|
|
pub tracecoins_amount: i32,
|
|
pub price: Option<i32>,
|
|
pub price_inr: Option<i32>,
|
|
pub is_active: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdatePackageRequest {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub role_key: Option<String>,
|
|
pub applicable_roles: Option<Vec<String>>,
|
|
pub tracecoins_amount: Option<i32>,
|
|
pub price: Option<i32>,
|
|
pub price_inr: Option<i32>,
|
|
pub is_active: Option<bool>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub is_active: bool,
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PricingPackageResponse {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub role_key: String,
|
|
pub applicable_roles: Vec<String>,
|
|
pub package_type: String,
|
|
pub tracecoins_amount: i32,
|
|
pub price: i32,
|
|
pub price_inr: 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 {
|
|
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<String>, applicable_roles: Option<Vec<String>>) -> Result<String, String> {
|
|
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<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).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<PricingPackageResponse> = 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<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> 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<AppState>,
|
|
Json(payload): Json<CreatePackageRequest>,
|
|
) -> 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<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
Json(payload): Json<UpdatePackageRequest>,
|
|
) -> 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<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> 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<AppState>,
|
|
Query(q): Query<PackageTypeQuery>,
|
|
) -> 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<PricingPackageResponse> = 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<AppState>,
|
|
Query(q): Query<PackageTypeQuery>,
|
|
) -> 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<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"data": packages,
|
|
"applicable_role": role
|
|
})))
|
|
.into_response()
|
|
}
|