diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 28d96e6..04dea66 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -82,6 +82,11 @@ impl Services { || path.starts_with("/api/support") || path.starts_with("/api/admin/kb") || path.starts_with("/api/admin/support-cases") + || path.starts_with("/api/admin/reviews") + || path.starts_with("/api/admin/coupons") + || path.starts_with("/api/admin/discounts") + || path.starts_with("/api/admin/tracecoin-packages") + || path.starts_with("/api/admin/reports") { Some(self.users_url.clone()) } diff --git a/apps/users/src/handlers/coupons.rs b/apps/users/src/handlers/coupons.rs new file mode 100644 index 0000000..f9db2c1 --- /dev/null +++ b/apps/users/src/handlers/coupons.rs @@ -0,0 +1,440 @@ +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() + } + } +} diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index ecbf75b..6348346 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -15,60 +15,136 @@ pub fn router() -> Router { } async fn get_metrics(State(state): State) -> Json { - // Return live scalar counts for Users, Companies, and Leads where possible let total_users: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users") .fetch_one(&state.pool) .await .unwrap_or(Some(0)) .unwrap_or(0); - let active_companies: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'") - .fetch_one(&state.pool) - .await - .unwrap_or(Some(0)) - .unwrap_or(0); + let active_companies: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'" + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); - let open_leads: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'") - .fetch_one(&state.pool) - .await - .unwrap_or(Some(0)) - .unwrap_or(0); + let open_leads: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'" + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let pending_approvals: i64 = sqlx::query_scalar!( + r#" + SELECT COUNT(*) FROM ( + SELECT id FROM company_profiles WHERE status = 'PENDING_APPROVAL' + UNION ALL + SELECT id FROM customer_profiles WHERE status = 'PENDING_APPROVAL' + UNION ALL + SELECT id FROM job_seeker_profiles WHERE status = 'PENDING_APPROVAL' + UNION ALL + SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL' + ) sub + "# + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let total_revenue: i64 = sqlx::query_scalar!( + "SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS'" + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); let kpis = vec![ json!({ "id": "users", "title": "Total Users", "value": format!("{}", total_users), "trend": "-", "trendUp": true }), json!({ "id": "companies", "title": "Active Companies", "value": format!("{}", active_companies), "trend": "-", "trendUp": true }), json!({ "id": "leads", "title": "Open Leads", "value": format!("{}", open_leads), "trend": "-", "trendUp": true }), - json!({ "id": "credits", "title": "Credits Purchased", "value": "$45,200", "trend": "+18%", "trendUp": true }), + json!({ "id": "approvals", "title": "Pending Approvals", "value": format!("{}", pending_approvals), "trend": "-", "trendUp": false }), + json!({ "id": "revenue", "title": "Total Revenue", "value": format!("₹{:.0}", total_revenue as f64 / 100.0), "trend": "-", "trendUp": true }), ]; - let trend_series = vec![ - json!({ "name": "Mon", "Freelancers": 40, "Agencies": 24 }), - json!({ "name": "Tue", "Freelancers": 30, "Agencies": 13 }), - json!({ "name": "Wed", "Freelancers": 20, "Agencies": 58 }), - json!({ "name": "Thu", "Freelancers": 27, "Agencies": 39 }), - json!({ "name": "Fri", "Freelancers": 18, "Agencies": 48 }), - json!({ "name": "Sat", "Freelancers": 23, "Agencies": 38 }), - json!({ "name": "Sun", "Freelancers": 34, "Agencies": 43 }), - ]; + // User registrations per day (last 7 days) + let trend_rows = sqlx::query!( + r#" + SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'Dy') AS day_name, + COUNT(*) AS count + FROM users + WHERE created_at >= NOW() - INTERVAL '7 days' + GROUP BY DATE_TRUNC('day', created_at), day_name + ORDER BY DATE_TRUNC('day', created_at) + "# + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); - let rev_series = vec![ - json!({ "name": "Week 1", "Revenue": 4000, "Profit": 2400 }), - json!({ "name": "Week 2", "Revenue": 3000, "Profit": 1398 }), - json!({ "name": "Week 3", "Revenue": 2000, "Profit": 9800 }), - json!({ "name": "Week 4", "Revenue": 2780, "Profit": 3908 }), - ]; + let trend_series: Vec = trend_rows + .into_iter() + .map(|r| json!({ "name": r.day_name.unwrap_or_default(), "Users": r.count.unwrap_or(0) })) + .collect(); - let lead_rows = vec![ - json!({ "id": "L-1001", "client": "Acme Corp", "service": "Photography", "status": "Open", "value": "$1,200", "date": "Oct 24, 2023" }), - json!({ "id": "L-1002", "client": "Stark Ind", "service": "Web Dev", "status": "In Progress", "value": "$4,500", "date": "Oct 23, 2023" }), - json!({ "id": "L-1003", "client": "Wayne Ent", "service": "SEO", "status": "Closed", "value": "$800", "date": "Oct 22, 2023" }), - json!({ "id": "L-1004", "client": "Daily Bugle", "service": "Copywriting", "status": "Open", "value": "$350", "date": "Oct 21, 2023" }), - ]; + // Revenue per week (last 4 weeks) + let rev_rows = sqlx::query!( + r#" + SELECT TO_CHAR(DATE_TRUNC('week', created_at), '"Week" W') AS week_name, + COALESCE(SUM(amount_inr), 0) AS total + FROM payments + WHERE status = 'SUCCESS' AND created_at >= NOW() - INTERVAL '28 days' + GROUP BY DATE_TRUNC('week', created_at), week_name + ORDER BY DATE_TRUNC('week', created_at) + "# + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let rev_series: Vec = rev_rows + .into_iter() + .map(|r| json!({ "name": r.week_name.unwrap_or_default(), "Revenue": r.total.unwrap_or(0) })) + .collect(); + + // Recent open leads + let recent_leads = sqlx::query!( + r#" + SELECT r.id, r.title, r.status, r.created_at, + u.full_name AS requester_name + FROM requirements r + LEFT JOIN customer_profiles cp ON cp.id = r.customer_id + LEFT JOIN users u ON u.id = cp.user_id + WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED') + ORDER BY r.created_at DESC + LIMIT 5 + "# + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let lead_rows: Vec = recent_leads + .into_iter() + .map(|r| { + json!({ + "id": format!("L-{}", &r.id.to_string()[..8].to_uppercase()), + "client": r.requester_name.unwrap_or_else(|| "Unknown".to_string()), + "service": r.title, + "status": r.status, + "date": r.created_at.format("%b %d, %Y").to_string() + }) + }) + .collect(); Json(DashboardMetricsResponse { kpis, - trend_series: trend_series, - rev_series: rev_series, - lead_rows: lead_rows, + trend_series, + rev_series, + lead_rows, }) } diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 7179758..756b696 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod approvals; pub mod auth; pub mod config; +pub mod coupons; pub mod dashboard; pub mod departments; pub mod designations; @@ -10,6 +11,8 @@ pub mod kb; pub mod notifications; pub mod onboarding; pub mod permissions; +pub mod pricing; +pub mod reviews; pub mod roles; pub mod support; pub mod user_roles; diff --git a/apps/users/src/handlers/pricing.rs b/apps/users/src/handlers/pricing.rs new file mode 100644 index 0000000..c469fdf --- /dev/null +++ b/apps/users/src/handlers/pricing.rs @@ -0,0 +1,355 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, 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 packages_router() -> Router { + Router::new() + .route("/", get(list_packages).post(create_package)) + .route("/:id", patch(update_package).delete(delete_package)) +} + +pub fn reports_router() -> Router { + Router::new() + .route("/users", get(report_users)) + .route("/revenue", get(report_revenue)) +} + +// ── Package DTOs ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct PackageDto { + id: Uuid, + name: String, + role_key: String, + package_type: String, + tracecoins_amount: i32, + price_inr: i32, + description: Option, + is_active: bool, +} + +#[derive(Deserialize)] +struct CreatePackageBody { + name: String, + role_key: String, + package_type: Option, + tracecoins_amount: Option, + price_inr: i32, + description: Option, +} + +#[derive(Deserialize)] +struct PatchPackageBody { + name: Option, + role_key: Option, + package_type: Option, + tracecoins_amount: Option, + price_inr: Option, + description: Option, + is_active: Option, +} + +// ── Report types ────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct DateRangeQuery { + from: Option, + to: Option, +} + +// ── Package handlers ────────────────────────────────────────────────────────── + +async fn list_packages( + _auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let rows = sqlx::query!( + r#" + SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active + FROM pricing_packages + ORDER BY role_key, price_inr + "# + ) + .fetch_all(&state.pool) + .await; + + match rows { + Ok(rows) => { + let dtos: Vec = rows + .into_iter() + .map(|r| PackageDto { + id: r.id, + name: r.name, + role_key: r.role_key, + package_type: r.package_type, + tracecoins_amount: r.tracecoins_amount, + price_inr: r.price_inr, + description: r.description, + is_active: r.is_active, + }) + .collect(); + (StatusCode::OK, Json(serde_json::json!({ "packages": dtos }))).into_response() + } + Err(e) => { + tracing::error!("Failed to list packages: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load packages" }))).into_response() + } + } +} + +async fn create_package( + _auth: AuthUser, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string()); + let tracecoins_amount = body.tracecoins_amount.unwrap_or(0); + + let row = sqlx::query!( + r#" + INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active + "#, + body.name, + body.role_key, + package_type, + tracecoins_amount, + body.price_inr, + body.description, + ) + .fetch_one(&state.pool) + .await; + + match row { + Ok(r) => { + let dto = PackageDto { + id: r.id, + name: r.name, + role_key: r.role_key, + package_type: r.package_type, + tracecoins_amount: r.tracecoins_amount, + price_inr: r.price_inr, + description: r.description, + is_active: r.is_active, + }; + (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() + } + Err(e) => { + tracing::error!("Failed to create package: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create package" }))).into_response() + } + } +} + +async fn update_package( + _auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let existing = sqlx::query!( + "SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages 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": "Package 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 name = body.name.unwrap_or(existing.name); + let role_key = body.role_key.unwrap_or(existing.role_key); + let package_type = body.package_type.unwrap_or(existing.package_type); + let tracecoins_amount = body.tracecoins_amount.unwrap_or(existing.tracecoins_amount); + let price_inr = body.price_inr.unwrap_or(existing.price_inr); + let description = body.description.or(existing.description); + let is_active = body.is_active.unwrap_or(existing.is_active); + + let result = sqlx::query!( + r#" + UPDATE pricing_packages + SET name = $1, role_key = $2, package_type = $3, tracecoins_amount = $4, + price_inr = $5, description = $6, is_active = $7 + WHERE id = $8 + "#, + name, + role_key, + package_type, + tracecoins_amount, + price_inr, + description, + 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 package {id}: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update package" }))).into_response() + } + } +} + +async fn delete_package( + _auth: AuthUser, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let result = sqlx::query!("UPDATE pricing_packages SET is_active = false 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": "Package not found" }))).into_response() + } + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + tracing::error!("Failed to deactivate package {id}: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to delete package" }))).into_response() + } + } +} + +// ── Report handlers ─────────────────────────────────────────────────────────── + +async fn report_users( + _auth: AuthUser, + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let from = params.from.as_deref().unwrap_or("2000-01-01"); + let to = params.to.as_deref().unwrap_or("2099-12-31"); + + let from_ts = match chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d") { + Ok(d) => d.and_hms_opt(0, 0, 0).unwrap().and_utc(), + Err(_) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid from date" }))).into_response(); + } + }; + let to_ts = match chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d") { + Ok(d) => d.and_hms_opt(23, 59, 59).unwrap().and_utc(), + Err(_) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid to date" }))).into_response(); + } + }; + + let total_users: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let new_users: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND created_at >= NOW() - INTERVAL '30 days'", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + // Active = email-verified users registered in range + let active_users: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND email_verified = true", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + (StatusCode::OK, Json(serde_json::json!({ + "total_users": total_users, + "new_users": new_users, + "active_users": active_users, + "from": from, + "to": to + }))).into_response() +} + +async fn report_revenue( + _auth: AuthUser, + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let from = params.from.as_deref().unwrap_or("2000-01-01"); + let to = params.to.as_deref().unwrap_or("2099-12-31"); + + let from_ts = match chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d") { + Ok(d) => d.and_hms_opt(0, 0, 0).unwrap().and_utc(), + Err(_) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid from date" }))).into_response(); + } + }; + let to_ts = match chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d") { + Ok(d) => d.and_hms_opt(23, 59, 59).unwrap().and_utc(), + Err(_) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid to date" }))).into_response(); + } + }; + + let total_revenue: i64 = sqlx::query_scalar!( + "SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let total_orders: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let total_tracecoins_sold: i64 = sqlx::query_scalar!( + "SELECT COALESCE(SUM(tracecoins_credited), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2", + from_ts, + to_ts + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + (StatusCode::OK, Json(serde_json::json!({ + "total_revenue": total_revenue, + "total_orders": total_orders, + "total_tracecoins_sold": total_tracecoins_sold, + "from": from, + "to": to + }))).into_response() +} diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs new file mode 100644 index 0000000..3656d9a --- /dev/null +++ b/apps/users/src/handlers/reviews.rs @@ -0,0 +1,221 @@ +use crate::AppState; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use contracts::auth_middleware::AuthUser; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── Router ──────────────────────────────────────────────────────────────────── + +pub fn admin_router() -> Router { + Router::new() + .route("/", get(admin_list_reviews).post(admin_create_review)) + .route("/:id", axum::routing::patch(admin_update_review).delete(admin_delete_review)) +} + +// ── DTOs ────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ReviewDto { + id: Uuid, + subject_type: String, + subject_id: Option, + reviewer_name: Option, + reviewer_id: Option, + rating: i16, + title: Option, + comment: Option, + status: String, + is_published: bool, + created_at: chrono::DateTime, +} + +#[derive(Deserialize)] +struct CreateReviewBody { + subject_type: Option, + subject_id: Option, + reviewer_name: Option, + rating: i16, + title: Option, + comment: Option, +} + +#[derive(Deserialize)] +struct PatchReviewBody { + status: Option, + is_published: Option, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn admin_list_reviews( + _auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let rows = sqlx::query!( + r#" + SELECT + r.id, + r.subject_type, + r.subject_id, + r.reviewer_name, + r.customer_id AS reviewer_id, + r.rating, + r.title, + r.comment, + r.status, + r.is_published, + r.created_at + FROM reviews r + ORDER BY r.created_at DESC + "# + ) + .fetch_all(&state.pool) + .await; + + match rows { + Ok(rows) => { + let dtos: Vec = rows + .into_iter() + .map(|r| ReviewDto { + id: r.id, + subject_type: r.subject_type, + subject_id: r.subject_id, + reviewer_name: r.reviewer_name, + reviewer_id: r.reviewer_id, + rating: r.rating, + title: r.title, + comment: r.comment, + status: r.status, + is_published: r.is_published, + created_at: r.created_at, + }) + .collect(); + (StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response() + } + Err(e) => { + tracing::error!("Failed to list reviews: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response() + } + } +} + +async fn admin_create_review( + _auth: AuthUser, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + if body.rating < 1 || body.rating > 5 { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response(); + } + + let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string()); + let status = "PUBLISHED".to_string(); + + let row = sqlx::query!( + r#" + INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id, + rating, title, comment, status, is_published, created_at + "#, + subject_type, + body.subject_id, + body.reviewer_name, + body.rating, + body.title, + body.comment, + status, + ) + .fetch_one(&state.pool) + .await; + + match row { + Ok(r) => { + let dto = ReviewDto { + id: r.id, + subject_type: r.subject_type, + subject_id: r.subject_id, + reviewer_name: r.reviewer_name, + reviewer_id: r.reviewer_id, + rating: r.rating, + title: r.title, + comment: r.comment, + status: r.status, + is_published: r.is_published, + created_at: r.created_at, + }; + (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() + } + Err(e) => { + tracing::error!("Failed to create review: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create review" }))).into_response() + } + } +} + +async fn admin_update_review( + _auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + // Derive is_published from status string, or use explicit field + let (status, published) = match (body.status.as_deref(), body.is_published) { + (Some("PUBLISHED"), _) => ("PUBLISHED".to_string(), true), + (Some("HIDDEN"), _) => ("HIDDEN".to_string(), false), + (Some(s), _) => (s.to_string(), false), + (None, Some(p)) => { + if p { ("PUBLISHED".to_string(), true) } else { ("HIDDEN".to_string(), false) } + } + (None, None) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Provide status or is_published" }))).into_response(); + } + }; + + let result = sqlx::query!( + "UPDATE reviews SET status = $1, is_published = $2, updated_at = NOW() WHERE id = $3", + status, + published, + id, + ) + .execute(&state.pool) + .await; + + match result { + Ok(r) if r.rows_affected() == 0 => { + (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Review not found" }))).into_response() + } + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), + Err(e) => { + tracing::error!("Failed to update review {id}: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update review" }))).into_response() + } + } +} + +async fn admin_delete_review( + _auth: AuthUser, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let result = sqlx::query!("DELETE FROM reviews 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": "Review not found" }))).into_response() + } + Ok(_) => (StatusCode::NO_CONTENT).into_response(), + Err(e) => { + tracing::error!("Failed to delete review {id}: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to delete review" }))).into_response() + } + } +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index c5aa722..daf70ce 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -85,6 +85,14 @@ async fn main() { .nest("/api/support/tickets", handlers::support::user_router()) // ── Support Tickets (admin) ─────────────────────────────────────── .nest("/api/admin/support-cases", handlers::support::admin_router()) + // ── Reviews (admin) ─────────────────────────────────────────────── + .nest("/api/admin/reviews", handlers::reviews::admin_router()) + // ── Coupons & Discounts (admin) ─────────────────────────────────── + .nest("/api/admin/coupons", handlers::coupons::coupons_router()) + .nest("/api/admin/discounts", handlers::coupons::discounts_router()) + // ── Tracecoin Packages & Reports (admin) ────────────────────────── + .nest("/api/admin/tracecoin-packages", handlers::pricing::packages_router()) + .nest("/api/admin/reports", handlers::pricing::reports_router()) .route("/health", get(|| async { "Users OK" })) .with_state(state); diff --git a/crates/db/migrations/20260402120000_reviews_admin_fields.down.sql b/crates/db/migrations/20260402120000_reviews_admin_fields.down.sql new file mode 100644 index 0000000..86539e9 --- /dev/null +++ b/crates/db/migrations/20260402120000_reviews_admin_fields.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE reviews + DROP COLUMN IF EXISTS title, + DROP COLUMN IF EXISTS subject_type, + DROP COLUMN IF EXISTS subject_id, + DROP COLUMN IF EXISTS reviewer_name, + DROP COLUMN IF EXISTS status; + +ALTER TABLE reviews + ALTER COLUMN lead_request_id SET NOT NULL, + ALTER COLUMN customer_id SET NOT NULL, + ALTER COLUMN professional_id SET NOT NULL; diff --git a/crates/db/migrations/20260402120000_reviews_admin_fields.up.sql b/crates/db/migrations/20260402120000_reviews_admin_fields.up.sql new file mode 100644 index 0000000..9f1b467 --- /dev/null +++ b/crates/db/migrations/20260402120000_reviews_admin_fields.up.sql @@ -0,0 +1,13 @@ +-- Extend reviews table to support admin-created reviews and admin moderation +ALTER TABLE reviews + ALTER COLUMN lead_request_id DROP NOT NULL, + ALTER COLUMN customer_id DROP NOT NULL, + ALTER COLUMN professional_id DROP NOT NULL, + ADD COLUMN IF NOT EXISTS title VARCHAR(255), + ADD COLUMN IF NOT EXISTS subject_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', + ADD COLUMN IF NOT EXISTS subject_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS reviewer_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED'; + +-- Sync status with is_published for existing rows +UPDATE reviews SET status = CASE WHEN is_published THEN 'PUBLISHED' ELSE 'HIDDEN' END; diff --git a/crates/db/migrations/20260402130000_coupons_title_roles.down.sql b/crates/db/migrations/20260402130000_coupons_title_roles.down.sql new file mode 100644 index 0000000..d95796b --- /dev/null +++ b/crates/db/migrations/20260402130000_coupons_title_roles.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE coupons + DROP COLUMN IF EXISTS title, + DROP COLUMN IF EXISTS role_keys; diff --git a/crates/db/migrations/20260402130000_coupons_title_roles.up.sql b/crates/db/migrations/20260402130000_coupons_title_roles.up.sql new file mode 100644 index 0000000..1c86a30 --- /dev/null +++ b/crates/db/migrations/20260402130000_coupons_title_roles.up.sql @@ -0,0 +1,7 @@ +-- Add title and role_keys to coupons for admin UI +ALTER TABLE coupons + ADD COLUMN IF NOT EXISTS title VARCHAR(255), + ADD COLUMN IF NOT EXISTS role_keys TEXT[] NOT NULL DEFAULT '{}'; + +-- Backfill title from description +UPDATE coupons SET title = description WHERE title IS NULL AND description IS NOT NULL; diff --git a/crates/db/migrations/20260402140000_discounts_table.down.sql b/crates/db/migrations/20260402140000_discounts_table.down.sql new file mode 100644 index 0000000..7bbe686 --- /dev/null +++ b/crates/db/migrations/20260402140000_discounts_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS discounts; diff --git a/crates/db/migrations/20260402140000_discounts_table.up.sql b/crates/db/migrations/20260402140000_discounts_table.up.sql new file mode 100644 index 0000000..1820640 --- /dev/null +++ b/crates/db/migrations/20260402140000_discounts_table.up.sql @@ -0,0 +1,12 @@ +-- Admin-managed automatic discounts (applied before coupon codes) +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'ROLE', -- ROLE, PACKAGE + role_key VARCHAR(50), + package_id UUID REFERENCES pricing_packages(id) ON DELETE SET NULL, + discount_type VARCHAR(20) NOT NULL, -- PERCENT, FIXED + discount_value INTEGER NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);