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::tracecoin_wallet::TracecoinWalletRepository; use db::models::requirement::RequirementRepository; use db::models::professional::{ CreatePortfolioItemPayload, CreateServicePayload, ProfessionalRepository, UpdatePortfolioItemPayload, UpdateServicePayload, }; use db::models::user_role_profile::UserRoleProfileRepository; 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() .route("/profile/submit", post({ let pk = profession_key; move |state, auth| submit_for_verification(state, auth, pk) })) // ── 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 user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, profession_key).await { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).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, &user_role_profile.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 TracecoinWalletRepository::get_by_user_id(&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 { user_role_profile_id: user_role_profile.id, expires_at: Utc::now() + chrono::Duration::hours(24), }; match LeadRequestRepository::create(&state.pool, db_payload).await { Ok(lead) => { let reserved = TracecoinWalletRepository::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, &user_role_profile.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? } "UGC_CONTENT_CREATOR" => { sqlx::query_scalar::<_, String>("SELECT status FROM ugc_content_creator_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(), } } // ── Lead request handlers ───────────────────────────────────────────────────── #[derive(Deserialize)] struct RequestsQuery { page: Option, limit: Option, status: Option, } async fn my_requests( State(state): State, auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(p) => p, Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), }; let page = q.page.unwrap_or(1).max(1); let limit = q.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; #[derive(serde::Serialize, sqlx::FromRow)] struct RichLeadReq { #[serde(flatten)] #[sqlx(flatten)] lead: db::models::lead_request::LeadRequest, req_title: Option, req_profession_key: Option, req_location: Option, req_budget: Option, customer_name: Option, customer_email: Option, customer_phone: Option, } let rows = if let Some(ref status) = q.status { sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN users u ON u.id = c.user_id WHERE lr.professional_id = $1 AND lr.status = $2 ORDER BY lr.requested_at DESC LIMIT $3 OFFSET $4 "# ) .bind(prof.id).bind(status).bind(limit).bind(offset).fetch_all(&state.pool).await } else { sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN users u ON u.id = c.user_id WHERE lr.professional_id = $1 ORDER BY lr.requested_at DESC LIMIT $2 OFFSET $3 "# ) .bind(prof.id).bind(limit).bind(offset).fetch_all(&state.pool).await }; let total: i64 = if let Some(ref status) = q.status { sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1 AND status = $2") .bind(prof.id).bind(status).fetch_one(&state.pool).await.unwrap_or(0) } else { sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1") .bind(prof.id).fetch_one(&state.pool).await.unwrap_or(0) }; match rows { Ok(data) => { let total_pages = if total == 0 { 1 } else { (total + limit - 1) / limit }; (StatusCode::OK, Json(serde_json::json!({ "data": data, "pagination": { "page": page, "limit": limit, "total": total, "total_pages": total_pages } }))).into_response() } Err(e) => { tracing::error!("Failed to fetch lead requests: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to fetch requests" }))).into_response() } } } async fn cancel_request( State(state): State, auth: AuthUser, Path(id): Path, ) -> impl IntoResponse { let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let lead = match LeadRequestRepository::get_by_id(&state.pool, id).await { Ok(Some(l)) => l, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Lead request not found" }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; if lead.user_role_profile_id != user_role_profile.id { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response(); } if lead.status != "PENDING" { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Cannot cancel a request with status '{}'", lead.status) }))).into_response(); } // Release reserved Tracecoins back to balance if lead.tracecoins_reserved > 0 { let _ = TracecoinWalletRepository::try_release_reserved_tracecoins( &state.pool, auth.user_id, lead.tracecoins_reserved, lead.id, "LEAD_CANCELLED", ).await; } match LeadRequestRepository::update_status(&state.pool, id, "CANCELLED").await { Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), } } async fn accepted_leads( State(state): State, auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let page = q.page.unwrap_or(1).max(1); let limit = q.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; let rows = sqlx::query( r#" SELECT lr.id AS lead_request_id, lr.status, lr.requested_at, lr.resolved_at, lr.tracecoins_reserved, lr.user_role_profile_id FROM lead_requests lr WHERE lr.user_role_profile_id = $1 AND lr.status = 'ACCEPTED' ORDER BY lr.resolved_at DESC LIMIT $2 OFFSET $3 "# ) .bind(user_role_profile.id) .bind(limit) .bind(offset) .fetch_all(&state.pool) .await; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1 AND status = 'ACCEPTED'" ) .bind(user_role_profile.id) .fetch_one(&state.pool) .await .unwrap_or(0); match rows { Ok(rows) => { use sqlx::Row; let data: Vec = rows.iter().map(|row| { serde_json::json!({ "lead_request_id": row.get::("lead_request_id"), "status": row.get::("status"), "requested_at": row.get::, _>("requested_at"), "resolved_at": row.try_get::, _>("resolved_at").ok(), "tracecoins_reserved": row.get::("tracecoins_reserved"), "user_role_profile_id": row.get::("user_role_profile_id"), }) }).collect(); let total_pages = if total == 0 { 1 } else { (total + limit - 1) / limit }; (StatusCode::OK, Json(serde_json::json!({ "data": data, "pagination": { "page": page, "limit": limit, "total": total, "total_pages": total_pages } }))).into_response() } Err(e) => { tracing::error!("Failed to fetch accepted leads: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to fetch accepted leads" }))).into_response() } } } async fn accepted_lead_detail( 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, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), }; let row = sqlx::query( r#" SELECT lr.id AS lead_id, lr.status, lr.tracecoins_reserved, lr.requested_at, lr.resolved_at, r.id AS requirement_id, r.title AS requirement_title, r.description AS requirement_description, r.location AS requirement_location, r.profession_key, r.custom_fields, CONCAT(u.first_name, ' ', u.last_name) AS name AS customer_name, u.email AS customer_email, u.phone AS customer_phone FROM lead_requests lr INNER JOIN requirements r ON r.id = lr.requirement_id INNER JOIN customers c ON c.id = r.customer_id INNER JOIN users u ON u.id = c.user_id WHERE lr.id = $1 AND lr.professional_id = $2 AND lr.status = 'ACCEPTED' "# ) .bind(id) .bind(prof.id) .fetch_optional(&state.pool) .await; match row { Ok(Some(row)) => { use sqlx::Row; let data = serde_json::json!({ "lead_id": row.get::("lead_id"), "status": row.get::("status"), "tracecoins_reserved": row.get::("tracecoins_reserved"), "requested_at": row.get::, _>("requested_at"), "resolved_at": row.try_get::, _>("resolved_at").ok(), "requirement": { "id": row.get::("requirement_id"), "title": row.get::("requirement_title"), "description": row.try_get::("requirement_description").ok(), "location": row.try_get::("requirement_location").ok(), "profession_key": row.get::("profession_key"), "custom_fields": row.try_get::("custom_fields").ok(), }, "customer": { "name": row.try_get::("customer_name").ok(), "email": row.get::("customer_email"), "phone": row.try_get::("customer_phone").ok(), } }); (StatusCode::OK, Json(data)).into_response() } Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Accepted lead not found" }))).into_response(), Err(e) => { tracing::error!("Failed to fetch accepted lead detail: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to fetch lead detail" }))).into_response() } } } 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(), } } async fn submit_for_verification( State(state): State, auth: AuthUser, profession_key: &'static str, ) -> 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(), }; if prof.status == "PENDING_REVIEW" || prof.status == "APPROVED" { return (StatusCode::BAD_REQUEST, format!("Profile is already {}", prof.status)).into_response(); } match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id, profession_key).await { Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ "status": profile.status, "message": "Profile submitted for verification" }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } }