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 { Router::new() .route("/", get(list_coupons).post(create_coupon)) .route("/{id}", patch(update_coupon).delete(delete_coupon)) } pub fn discounts_router() -> Router { 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, #[serde(rename = "type")] discount_type: String, value: i32, min_order_amount: i32, usage_limit: Option, used_count: i32, role_keys: Vec, applies_to: String, is_active: bool, valid_until: Option>, } #[derive(Deserialize)] struct CreateCouponBody { code: String, title: Option, #[serde(rename = "type")] discount_type: Option, value: Option, min_order_amount: Option, max_uses: Option, role_keys: Option>, applies_to: Option, } #[derive(Deserialize)] struct PatchCouponBody { code: Option, title: Option, #[serde(rename = "type")] discount_type: Option, value: Option, min_order_amount: Option, max_uses: Option, role_keys: Option>, is_active: Option, } // ── Discount DTOs ───────────────────────────────────────────────────────────── #[derive(Serialize)] struct DiscountDto { id: Uuid, title: String, scope: String, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: String, value: i32, is_active: bool, } #[derive(Deserialize)] struct CreateDiscountBody { title: String, scope: Option, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: Option, value: Option, } #[derive(Deserialize)] struct PatchDiscountBody { title: Option, scope: Option, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: Option, value: Option, is_active: Option, } // ── Coupon handlers ─────────────────────────────────────────────────────────── async fn list_coupons( _auth: AuthUser, State(state): State, ) -> 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 = 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, Json(body): Json, ) -> 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 = 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, Path(id): Path, Json(body): Json, ) -> 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, Path(id): Path, ) -> 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, ) -> 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 = 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, Json(body): Json, ) -> 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, Path(id): Path, Json(body): Json, ) -> 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() } } }