419 lines
14 KiB
Rust
419 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()
|
||
|
|
}
|