Add reviews, coupons, discounts, pricing packages, and reports handlers
- handlers/reviews.rs: admin CRUD for /api/admin/reviews (list, create, patch status, delete)
- handlers/coupons.rs: admin CRUD for /api/admin/coupons and /api/admin/discounts
- handlers/pricing.rs: admin CRUD for /api/admin/tracecoin-packages + /api/admin/reports/{users,revenue}
- handlers/dashboard.rs: replace all hardcoded fake data with real DB queries (registrations per day, revenue per week, live KPIs including pending approvals and total revenue)
- Migrations: extend reviews table (nullable FKs + admin fields), add coupons.title/role_keys, create discounts table
- gateway: route new admin paths to users service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
96f9da2cdb
commit
d900c361d8
13 changed files with 1191 additions and 36 deletions
|
|
@ -82,6 +82,11 @@ impl Services {
|
||||||
|| path.starts_with("/api/support")
|
|| path.starts_with("/api/support")
|
||||||
|| path.starts_with("/api/admin/kb")
|
|| path.starts_with("/api/admin/kb")
|
||||||
|| path.starts_with("/api/admin/support-cases")
|
|| 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())
|
Some(self.users_url.clone())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
440
apps/users/src/handlers/coupons.rs
Normal file
440
apps/users/src/handlers/coupons.rs
Normal file
|
|
@ -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<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,60 +15,136 @@ pub fn router() -> Router<crate::AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetricsResponse> {
|
async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetricsResponse> {
|
||||||
// Return live scalar counts for Users, Companies, and Leads where possible
|
|
||||||
let total_users: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
|
let total_users: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
.unwrap_or(Some(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let active_companies: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'")
|
let active_companies: i64 = sqlx::query_scalar!(
|
||||||
.fetch_one(&state.pool)
|
"SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'"
|
||||||
.await
|
)
|
||||||
.unwrap_or(Some(0))
|
.fetch_one(&state.pool)
|
||||||
.unwrap_or(0);
|
.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'")
|
let open_leads: i64 = sqlx::query_scalar!(
|
||||||
.fetch_one(&state.pool)
|
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'"
|
||||||
.await
|
)
|
||||||
.unwrap_or(Some(0))
|
.fetch_one(&state.pool)
|
||||||
.unwrap_or(0);
|
.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![
|
let kpis = vec![
|
||||||
json!({ "id": "users", "title": "Total Users", "value": format!("{}", total_users), "trend": "-", "trendUp": true }),
|
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": "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": "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![
|
// User registrations per day (last 7 days)
|
||||||
json!({ "name": "Mon", "Freelancers": 40, "Agencies": 24 }),
|
let trend_rows = sqlx::query!(
|
||||||
json!({ "name": "Tue", "Freelancers": 30, "Agencies": 13 }),
|
r#"
|
||||||
json!({ "name": "Wed", "Freelancers": 20, "Agencies": 58 }),
|
SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'Dy') AS day_name,
|
||||||
json!({ "name": "Thu", "Freelancers": 27, "Agencies": 39 }),
|
COUNT(*) AS count
|
||||||
json!({ "name": "Fri", "Freelancers": 18, "Agencies": 48 }),
|
FROM users
|
||||||
json!({ "name": "Sat", "Freelancers": 23, "Agencies": 38 }),
|
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||||
json!({ "name": "Sun", "Freelancers": 34, "Agencies": 43 }),
|
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![
|
let trend_series: Vec<Value> = trend_rows
|
||||||
json!({ "name": "Week 1", "Revenue": 4000, "Profit": 2400 }),
|
.into_iter()
|
||||||
json!({ "name": "Week 2", "Revenue": 3000, "Profit": 1398 }),
|
.map(|r| json!({ "name": r.day_name.unwrap_or_default(), "Users": r.count.unwrap_or(0) }))
|
||||||
json!({ "name": "Week 3", "Revenue": 2000, "Profit": 9800 }),
|
.collect();
|
||||||
json!({ "name": "Week 4", "Revenue": 2780, "Profit": 3908 }),
|
|
||||||
];
|
|
||||||
|
|
||||||
let lead_rows = vec![
|
// Revenue per week (last 4 weeks)
|
||||||
json!({ "id": "L-1001", "client": "Acme Corp", "service": "Photography", "status": "Open", "value": "$1,200", "date": "Oct 24, 2023" }),
|
let rev_rows = sqlx::query!(
|
||||||
json!({ "id": "L-1002", "client": "Stark Ind", "service": "Web Dev", "status": "In Progress", "value": "$4,500", "date": "Oct 23, 2023" }),
|
r#"
|
||||||
json!({ "id": "L-1003", "client": "Wayne Ent", "service": "SEO", "status": "Closed", "value": "$800", "date": "Oct 22, 2023" }),
|
SELECT TO_CHAR(DATE_TRUNC('week', created_at), '"Week" W') AS week_name,
|
||||||
json!({ "id": "L-1004", "client": "Daily Bugle", "service": "Copywriting", "status": "Open", "value": "$350", "date": "Oct 21, 2023" }),
|
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<Value> = 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<Value> = 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 {
|
Json(DashboardMetricsResponse {
|
||||||
kpis,
|
kpis,
|
||||||
trend_series: trend_series,
|
trend_series,
|
||||||
rev_series: rev_series,
|
rev_series,
|
||||||
lead_rows: lead_rows,
|
lead_rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod admin;
|
||||||
pub mod approvals;
|
pub mod approvals;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod coupons;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod departments;
|
pub mod departments;
|
||||||
pub mod designations;
|
pub mod designations;
|
||||||
|
|
@ -10,6 +11,8 @@ pub mod kb;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
|
pub mod pricing;
|
||||||
|
pub mod reviews;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
pub mod support;
|
pub mod support;
|
||||||
pub mod user_roles;
|
pub mod user_roles;
|
||||||
|
|
|
||||||
355
apps/users/src/handlers/pricing.rs
Normal file
355
apps/users/src/handlers/pricing.rs
Normal file
|
|
@ -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<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_packages).post(create_package))
|
||||||
|
.route("/:id", patch(update_package).delete(delete_package))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reports_router() -> Router<AppState> {
|
||||||
|
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<String>,
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreatePackageBody {
|
||||||
|
name: String,
|
||||||
|
role_key: String,
|
||||||
|
package_type: Option<String>,
|
||||||
|
tracecoins_amount: Option<i32>,
|
||||||
|
price_inr: i32,
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PatchPackageBody {
|
||||||
|
name: Option<String>,
|
||||||
|
role_key: Option<String>,
|
||||||
|
package_type: Option<String>,
|
||||||
|
tracecoins_amount: Option<i32>,
|
||||||
|
price_inr: Option<i32>,
|
||||||
|
description: Option<String>,
|
||||||
|
is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DateRangeQuery {
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Package handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list_packages(
|
||||||
|
_auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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<PackageDto> = 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<AppState>,
|
||||||
|
Json(body): Json<CreatePackageBody>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<PatchPackageBody>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Query(params): Query<DateRangeQuery>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Query(params): Query<DateRangeQuery>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
221
apps/users/src/handlers/reviews.rs
Normal file
221
apps/users/src/handlers/reviews.rs
Normal file
|
|
@ -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<AppState> {
|
||||||
|
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<String>,
|
||||||
|
reviewer_name: Option<String>,
|
||||||
|
reviewer_id: Option<Uuid>,
|
||||||
|
rating: i16,
|
||||||
|
title: Option<String>,
|
||||||
|
comment: Option<String>,
|
||||||
|
status: String,
|
||||||
|
is_published: bool,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateReviewBody {
|
||||||
|
subject_type: Option<String>,
|
||||||
|
subject_id: Option<String>,
|
||||||
|
reviewer_name: Option<String>,
|
||||||
|
rating: i16,
|
||||||
|
title: Option<String>,
|
||||||
|
comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PatchReviewBody {
|
||||||
|
status: Option<String>,
|
||||||
|
is_published: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn admin_list_reviews(
|
||||||
|
_auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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<ReviewDto> = 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<AppState>,
|
||||||
|
Json(body): Json<CreateReviewBody>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<PatchReviewBody>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,14 @@ async fn main() {
|
||||||
.nest("/api/support/tickets", handlers::support::user_router())
|
.nest("/api/support/tickets", handlers::support::user_router())
|
||||||
// ── Support Tickets (admin) ───────────────────────────────────────
|
// ── Support Tickets (admin) ───────────────────────────────────────
|
||||||
.nest("/api/admin/support-cases", handlers::support::admin_router())
|
.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" }))
|
.route("/health", get(|| async { "Users OK" }))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE coupons
|
||||||
|
DROP COLUMN IF EXISTS title,
|
||||||
|
DROP COLUMN IF EXISTS role_keys;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS discounts;
|
||||||
12
crates/db/migrations/20260402140000_discounts_table.up.sql
Normal file
12
crates/db/migrations/20260402140000_discounts_table.up.sql
Normal file
|
|
@ -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()
|
||||||
|
);
|
||||||
Loading…
Add table
Reference in a new issue