nxtgauge-backend-rust/crates/contracts/src/profession_shared.rs

505 lines
21 KiB
Rust
Raw Normal View History

use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
use db::models::professional::{
CreatePortfolioItemPayload,
CreateServicePayload,
ProfessionalRepository,
UpdatePortfolioItemPayload,
UpdateServicePayload,
};
use db::models::requirement::RequirementRepository;
use crate::auth_middleware::AuthUser;
use crate::ProfessionState;
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct LeadRequestPayload {
pub requirement_id: Uuid,
}
/// Build the shared Router that every profession service merges into its own Router.
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
Router::new()
// ── Marketplace (Redis-cached) ────────────────────────────────────────
.route(
"/marketplace",
get(move |State(state): State<ProfessionState>, Query(q): Query<PaginationQuery>| async move {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
// Try cache first
let mut redis = state.redis.clone();
if let Ok(Some(cached)) = cache::jobs::get_marketplace(&mut redis, profession_key, page, limit).await {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
match ProfessionalRepository::get_marketplace(&state.pool, profession_key, page, limit).await {
Ok(items) => {
let body = serde_json::json!({
"data": items,
"pagination": { "page": page, "limit": limit }
});
// Write to cache (best-effort)
let _ = cache::jobs::set_marketplace(
&mut redis,
profession_key,
page,
limit,
&body.to_string(),
).await;
(StatusCode::OK, Json(body)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}),
)
.route("/marketplace/{id}", get(get_requirement))
// ── Lead Requests ────────────────────────────────────────────────────
.route(
"/leads/request",
post(
move |state: State<ProfessionState>, auth: AuthUser, payload: Json<LeadRequestPayload>| async move {
send_lead_request(state, auth, payload, profession_key).await
},
),
)
.route("/leads/requests/me", get(my_requests))
.route("/leads/requests/{id}", delete(cancel_request))
.route("/leads/accepted/me", get(accepted_leads))
.route("/leads/accepted/{id}", get(accepted_lead_detail))
// ── Portfolio ────────────────────────────────────────────────────────
.route("/portfolio/me", get(list_portfolio).post(create_portfolio_item))
.route("/portfolio/me/{id}", patch(update_portfolio_item).delete(delete_portfolio_item))
// ── Services ─────────────────────────────────────────────────────────
.route("/services/me", get(list_services).post(create_service))
.route("/services/me/{id}", patch(update_service).delete(delete_service))
// ── Wallet ───────────────────────────────────────────────────────────
.route("/wallet/me", get(wallet_balance))
.route("/wallet/me/ledger", get(wallet_ledger))
.route("/wallet/me/invoices", get(wallet_invoices))
.route("/wallet/me/invoices/{id}", get(wallet_invoice_detail))
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn get_requirement(
State(state): State<ProfessionState>,
_auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(req)) if req.status == "OPEN" => (StatusCode::OK, Json(req)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Requirement is not open").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn send_lead_request(
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<LeadRequestPayload>,
profession_key: &'static str,
) -> impl IntoResponse {
let mut redis = state.redis.clone();
// ── Rate limit: max 5 lead requests per hour per professional ─────────────
let allowed = cache::rate_limit::check_lead(&mut redis, &auth.user_id.to_string())
.await
.unwrap_or(true);
if !allowed {
return (StatusCode::TOO_MANY_REQUESTS, "Too many lead requests. Try again later.").into_response();
}
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match is_professional_profile_approved(&state.pool, auth.user_id, profession_key).await {
Ok(true) => {}
Ok(false) => {
return (
StatusCode::FORBIDDEN,
"Professional profile approval is required before sending lead requests",
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to check profile approval: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to validate profile approval").into_response();
}
}
// ── Deduplication: one lead per requirement per professional (24 h) ────────
let duplicate = cache::lead::is_duplicate(
&mut redis,
&prof.id.to_string(),
&payload.requirement_id.to_string(),
)
.await
.unwrap_or(false);
if duplicate {
return (StatusCode::CONFLICT, "You have already sent a lead request for this requirement").into_response();
}
let req = match RequirementRepository::get_by_id(&state.pool, payload.requirement_id).await {
Ok(Some(r)) if r.status == "OPEN" => r,
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Requirement is not open").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
if req.request_count >= 20 {
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
}
let wallet = match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
Ok(w) => w,
Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
};
if wallet.balance < 25 {
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
}
let db_payload = CreateLeadRequestPayload {
requirement_id: req.id,
professional_id: prof.id,
expires_at: Utc::now() + chrono::Duration::hours(24),
};
match LeadRequestRepository::create(&state.pool, db_payload).await {
Ok(lead) => {
let reserved = ProfessionalRepository::try_reserve_tracecoins(
&state.pool,
auth.user_id,
lead.tracecoins_reserved,
lead.id,
)
.await;
match reserved {
Ok(true) => {}
Ok(false) => {
let _ = LeadRequestRepository::update_status(&state.pool, lead.id, "CANCELLED").await;
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
}
Err(e) => {
let _ = LeadRequestRepository::update_status(&state.pool, lead.id, "CANCELLED").await;
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
}
let _ = RequirementRepository::increment_request_count(&state.pool, req.id).await;
// Mark dedup in Redis so this professional can't spam the same requirement
let _ = cache::lead::mark_sent(
&mut redis,
&prof.id.to_string(),
&payload.requirement_id.to_string(),
)
.await;
(StatusCode::CREATED, Json(lead)).into_response()
}
Err(e) => {
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Already requested this lead").into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
}
async fn is_professional_profile_approved(
pool: &sqlx::PgPool,
user_id: Uuid,
profession_key: &str,
) -> Result<bool, sqlx::Error> {
let status = match profession_key {
"PHOTOGRAPHER" => {
sqlx::query_scalar::<_, String>("SELECT status FROM photographer_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"MAKEUP_ARTIST" => {
sqlx::query_scalar::<_, String>("SELECT status FROM makeup_artist_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"TUTOR" => {
sqlx::query_scalar::<_, String>("SELECT status FROM tutor_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"DEVELOPER" => {
sqlx::query_scalar::<_, String>("SELECT status FROM developer_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"VIDEO_EDITOR" => {
sqlx::query_scalar::<_, String>("SELECT status FROM video_editor_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"GRAPHIC_DESIGNER" => {
sqlx::query_scalar::<_, String>("SELECT status FROM graphic_designer_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"SOCIAL_MEDIA_MANAGER" => {
sqlx::query_scalar::<_, String>("SELECT status FROM social_media_manager_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"FITNESS_TRAINER" => {
sqlx::query_scalar::<_, String>("SELECT status FROM fitness_trainer_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
"CATERING_SERVICES" => {
sqlx::query_scalar::<_, String>("SELECT status FROM catering_service_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
}
_ => None,
};
Ok(matches!(status.as_deref(), Some("APPROVED")))
}
async fn list_portfolio(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
},
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
}
}
async fn list_services(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(prof) => match ProfessionalRepository::get_services(&state.pool, prof.id).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
},
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
}
}
async fn wallet_balance(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
Ok(w) => (StatusCode::OK, Json(w)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// ── Stub handlers ─────────────────────────────────────────────────────────────
async fn my_requests(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn cancel_request(
_s: State<ProfessionState>,
_a: AuthUser,
_p: Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Cancelled" })))
}
async fn accepted_leads(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn accepted_lead_detail(
_s: State<ProfessionState>,
_a: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
}
async fn create_portfolio_item(
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<CreatePortfolioItemPayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::create_portfolio_item(&state.pool, prof.id, payload).await {
Ok(item) => (StatusCode::CREATED, Json(item)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_portfolio_item(
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePortfolioItemPayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::update_portfolio_item(&state.pool, prof.id, id, payload).await {
Ok(Some(item)) => (StatusCode::OK, Json(item)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Portfolio item not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn delete_portfolio_item(
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::delete_portfolio_item(&state.pool, prof.id, id).await {
Ok(true) => (StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" }))).into_response(),
Ok(false) => (StatusCode::NOT_FOUND, "Portfolio item not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_service(
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<CreateServicePayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::create_service(&state.pool, prof.id, payload).await {
Ok(svc) => (StatusCode::CREATED, Json(svc)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_service(
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateServicePayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::update_service(&state.pool, prof.id, id, payload).await {
Ok(Some(svc)) => (StatusCode::OK, Json(svc)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Service not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn delete_service(
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
match ProfessionalRepository::delete_service(&state.pool, prof.id, id).await {
Ok(true) => (StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" }))).into_response(),
Ok(false) => (StatusCode::NOT_FOUND, "Service not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn wallet_ledger(
State(state): State<ProfessionState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ProfessionalRepository::list_wallet_ledger(&state.pool, auth.user_id, page, limit).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({
"data": items,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn wallet_invoices(
State(state): State<ProfessionState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ProfessionalRepository::list_wallet_invoices(&state.pool, auth.user_id, page, limit).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({
"data": items,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn wallet_invoice_detail(
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match ProfessionalRepository::get_invoice_by_id_for_user(&state.pool, auth.user_id, id).await {
Ok(Some(inv)) => (StatusCode::OK, Json(inv)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Invoice not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}