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, pub limit: Option, } #[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 { Router::new() // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", get(move |State(state): State, Query(q): Query| 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::(&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, auth: AuthUser, payload: Json| 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, _auth: AuthUser, Path(id): Path, ) -> 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, auth: AuthUser, Json(payload): Json, 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 { 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, 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, 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, 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, _a: AuthUser, _q: Query, ) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) } async fn cancel_request( _s: State, _a: AuthUser, _p: Path, ) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({ "message": "Cancelled" }))) } async fn accepted_leads( _s: State, _a: AuthUser, _q: Query, ) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) } async fn accepted_lead_detail( _s: State, _a: AuthUser, Path(id): Path, ) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() }))) } async fn create_portfolio_item( State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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, auth: AuthUser, Path(id): Path, Json(payload): Json, ) -> 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, auth: AuthUser, Path(id): Path, ) -> 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, auth: AuthUser, Json(payload): Json, ) -> 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, auth: AuthUser, Path(id): Path, Json(payload): Json, ) -> 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, auth: AuthUser, Path(id): Path, ) -> 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, auth: AuthUser, Query(q): Query, ) -> 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, auth: AuthUser, Query(q): Query, ) -> 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, auth: AuthUser, Path(id): Path, ) -> 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(), } }