nxtgauge-backend-rust/apps/users/src/handlers/coupons.rs
Ashwin Kumar 5451ff5657 Fix users service startup panic — route syntax and admin login
- All /:param routes converted to /{param} (Axum v0.8 breaking change)
- admin.rs, kb.rs, reviews.rs, support.rs, pricing.rs, coupons.rs fixed
- employees app routes fixed (departments, designations, employees handlers)
- kb.rs multiline route definitions also fixed
- Users service now boots successfully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:32:46 +02:00

440 lines
14 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Routers ───────────────────────────────────────────────────────────────────
pub fn coupons_router() -> Router<AppState> {
Router::new()
.route("/", get(list_coupons).post(create_coupon))
.route("/{id}", patch(update_coupon).delete(delete_coupon))
}
pub fn discounts_router() -> Router<AppState> {
Router::new()
.route("/", get(list_discounts).post(create_discount))
.route("/{id}", patch(update_discount))
}
// ── Coupon DTOs ───────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct CouponDto {
id: Uuid,
code: String,
title: Option<String>,
#[serde(rename = "type")]
discount_type: String,
value: i32,
min_order_amount: i32,
usage_limit: Option<i32>,
used_count: i32,
role_keys: Vec<String>,
applies_to: String,
is_active: bool,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
struct CreateCouponBody {
code: String,
title: Option<String>,
#[serde(rename = "type")]
discount_type: Option<String>,
value: Option<i32>,
min_order_amount: Option<i32>,
max_uses: Option<i32>,
role_keys: Option<Vec<String>>,
applies_to: Option<String>,
}
#[derive(Deserialize)]
struct PatchCouponBody {
code: Option<String>,
title: Option<String>,
#[serde(rename = "type")]
discount_type: Option<String>,
value: Option<i32>,
min_order_amount: Option<i32>,
max_uses: Option<i32>,
role_keys: Option<Vec<String>>,
is_active: Option<bool>,
}
// ── Discount DTOs ─────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct DiscountDto {
id: Uuid,
title: String,
scope: String,
role_key: Option<String>,
package_id: Option<Uuid>,
#[serde(rename = "type")]
discount_type: String,
value: i32,
is_active: bool,
}
#[derive(Deserialize)]
struct CreateDiscountBody {
title: String,
scope: Option<String>,
role_key: Option<String>,
package_id: Option<Uuid>,
#[serde(rename = "type")]
discount_type: Option<String>,
value: Option<i32>,
}
#[derive(Deserialize)]
struct PatchDiscountBody {
title: Option<String>,
scope: Option<String>,
role_key: Option<String>,
package_id: Option<Uuid>,
#[serde(rename = "type")]
discount_type: Option<String>,
value: Option<i32>,
is_active: Option<bool>,
}
// ── Coupon handlers ───────────────────────────────────────────────────────────
async fn list_coupons(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
r#"
SELECT id, code, title, discount_type, discount_value, min_order_amount,
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
FROM coupons
ORDER BY created_at DESC
"#
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let dtos: Vec<CouponDto> = rows
.into_iter()
.map(|r| CouponDto {
id: r.id,
code: r.code,
title: r.title,
discount_type: r.discount_type,
value: r.discount_value,
min_order_amount: r.min_order_amount,
usage_limit: r.max_uses,
used_count: r.uses_count,
role_keys: r.role_keys,
applies_to: r.applies_to,
is_active: r.is_active,
valid_until: r.valid_until,
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "coupons": dtos }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list coupons: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load coupons" }))).into_response()
}
}
}
async fn create_coupon(
_auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateCouponBody>,
) -> impl IntoResponse {
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
let value = body.value.unwrap_or(0);
let min_order = body.min_order_amount.unwrap_or(0);
let applies_to = body.applies_to.unwrap_or_else(|| "ALL".to_string());
let role_keys: Vec<String> = body.role_keys.unwrap_or_default();
let code = body.code.to_uppercase();
let row = sqlx::query!(
r#"
INSERT INTO coupons (code, title, discount_type, discount_value, min_order_amount,
max_uses, role_keys, applies_to)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, code, title, discount_type, discount_value, min_order_amount,
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
"#,
code,
body.title,
discount_type,
value,
min_order,
body.max_uses,
&role_keys,
applies_to,
)
.fetch_one(&state.pool)
.await;
match row {
Ok(r) => {
let dto = CouponDto {
id: r.id,
code: r.code,
title: r.title,
discount_type: r.discount_type,
value: r.discount_value,
min_order_amount: r.min_order_amount,
usage_limit: r.max_uses,
used_count: r.uses_count,
role_keys: r.role_keys,
applies_to: r.applies_to,
is_active: r.is_active,
valid_until: r.valid_until,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
}
Err(e) => {
tracing::error!("Failed to create coupon: {e}");
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, Json(serde_json::json!({ "error": "Coupon code already exists" }))).into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create coupon" }))).into_response()
}
}
}
}
async fn update_coupon(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<PatchCouponBody>,
) -> impl IntoResponse {
// Build dynamic update using individual queries for simplicity
let existing = sqlx::query!(
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
id
)
.fetch_optional(&state.pool)
.await;
let existing = match existing {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Coupon not found" }))).into_response(),
Err(e) => {
tracing::error!("DB error: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "DB error" }))).into_response();
}
};
let code = body.code.map(|c| c.to_uppercase()).unwrap_or(existing.code);
let title = body.title.or(existing.title);
let discount_type = body.discount_type.unwrap_or(existing.discount_type);
let value = body.value.unwrap_or(existing.discount_value);
let min_order = body.min_order_amount.unwrap_or(existing.min_order_amount);
let max_uses = body.max_uses.or(existing.max_uses);
let role_keys = body.role_keys.unwrap_or(existing.role_keys);
let is_active = body.is_active.unwrap_or(existing.is_active);
let result = sqlx::query!(
r#"
UPDATE coupons
SET code = $1, title = $2, discount_type = $3, discount_value = $4,
min_order_amount = $5, max_uses = $6, role_keys = $7, is_active = $8
WHERE id = $9
"#,
code,
title,
discount_type,
value,
min_order,
max_uses,
&role_keys,
is_active,
id,
)
.execute(&state.pool)
.await;
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => {
tracing::error!("Failed to update coupon {id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update coupon" }))).into_response()
}
}
}
async fn delete_coupon(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!("DELETE FROM coupons WHERE id = $1", id)
.execute(&state.pool)
.await;
match result {
Ok(r) if r.rows_affected() == 0 => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Coupon not found" }))).into_response()
}
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
tracing::error!("Failed to delete coupon {id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to delete coupon" }))).into_response()
}
}
}
// ── Discount handlers ─────────────────────────────────────────────────────────
async fn list_discounts(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
r#"
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
FROM discounts
ORDER BY created_at DESC
"#
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let dtos: Vec<DiscountDto> = rows
.into_iter()
.map(|r| DiscountDto {
id: r.id,
title: r.title,
scope: r.scope,
role_key: r.role_key,
package_id: r.package_id,
discount_type: r.discount_type,
value: r.discount_value,
is_active: r.is_active,
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "discounts": dtos }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list discounts: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load discounts" }))).into_response()
}
}
}
async fn create_discount(
_auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateDiscountBody>,
) -> impl IntoResponse {
let scope = body.scope.unwrap_or_else(|| "ROLE".to_string());
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
let value = body.value.unwrap_or(0);
let row = sqlx::query!(
r#"
INSERT INTO discounts (title, scope, role_key, package_id, discount_type, discount_value)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, scope, role_key, package_id, discount_type, discount_value, is_active
"#,
body.title,
scope,
body.role_key,
body.package_id,
discount_type,
value,
)
.fetch_one(&state.pool)
.await;
match row {
Ok(r) => {
let dto = DiscountDto {
id: r.id,
title: r.title,
scope: r.scope,
role_key: r.role_key,
package_id: r.package_id,
discount_type: r.discount_type,
value: r.discount_value,
is_active: r.is_active,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
}
Err(e) => {
tracing::error!("Failed to create discount: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create discount" }))).into_response()
}
}
}
async fn update_discount(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<PatchDiscountBody>,
) -> impl IntoResponse {
let existing = sqlx::query!(
"SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1",
id
)
.fetch_optional(&state.pool)
.await;
let existing = match existing {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Discount not found" }))).into_response(),
Err(e) => {
tracing::error!("DB error: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "DB error" }))).into_response();
}
};
let title = body.title.unwrap_or(existing.title);
let scope = body.scope.unwrap_or(existing.scope);
let role_key = body.role_key.or(existing.role_key);
let package_id = body.package_id.or(existing.package_id);
let discount_type = body.discount_type.unwrap_or(existing.discount_type);
let value = body.value.unwrap_or(existing.discount_value);
let is_active = body.is_active.unwrap_or(existing.is_active);
let result = sqlx::query!(
r#"
UPDATE discounts
SET title = $1, scope = $2, role_key = $3, package_id = $4,
discount_type = $5, discount_value = $6, is_active = $7
WHERE id = $8
"#,
title,
scope,
role_key,
package_id,
discount_type,
value,
is_active,
id,
)
.execute(&state.pool)
.await;
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => {
tracing::error!("Failed to update discount {id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update discount" }))).into_response()
}
}
}