451 lines
18 KiB
Rust
451 lines
18 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
|
|
use db::models::professional::ProfessionalRepository;
|
|
use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload};
|
|
use db::models::lead_request::LeadRequestRepository;
|
|
use db::models::user::UserRepository;
|
|
use db::models::verification::VerificationRepository;
|
|
use contracts::auth_middleware::AuthUser;
|
|
use crate::AppState;
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/profile/me", get(get_profile).patch(update_profile))
|
|
.route("/profile/submit", post(submit_for_verification))
|
|
.route("/requirements", get(list_requirements).post(create_requirement))
|
|
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
|
|
.route("/requirements/{id}/submit", post(submit_requirement))
|
|
.route("/requirements/{id}/requests", get(list_requests))
|
|
.route("/requirements/{id}/requests/{lead_id}/approve", post(approve_request))
|
|
.route("/requirements/{id}/requests/{lead_id}/reject", post(reject_request))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PaginationQuery {
|
|
pub page: Option<i64>,
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateRequirementRequest {
|
|
pub profession_key: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub location: String,
|
|
pub budget: Option<i32>,
|
|
pub preferred_date: Option<String>,
|
|
pub extra_data_json: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct RejectRequestPayload {
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
async fn get_profile(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
|
Ok(None) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn update_profile(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<UpsertCustomerProfilePayload>,
|
|
) -> impl IntoResponse {
|
|
match CustomerRepository::upsert(&state.pool, auth.user_id, payload).await {
|
|
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn submit_for_verification(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => return (StatusCode::NOT_FOUND, "Customer profile not found").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
if matches!(
|
|
customer.status.as_str(),
|
|
"PENDING_REVIEW"
|
|
| "PENDING"
|
|
| "UNDER_REVIEW"
|
|
| "DOCUMENTS_REQUESTED"
|
|
| "REVISION_REQUESTED"
|
|
| "APPROVED"
|
|
) {
|
|
return (StatusCode::BAD_REQUEST, format!("Profile is already {}", customer.status)).into_response();
|
|
}
|
|
|
|
match CustomerRepository::submit_for_verification(&state.pool, auth.user_id).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(),
|
|
}
|
|
}
|
|
|
|
async fn list_requirements(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
let page = q.page.unwrap_or(1);
|
|
let limit = q.limit.unwrap_or(20);
|
|
match RequirementRepository::list_by_customer_id(&state.pool, customer.id, page, limit).await {
|
|
Ok(reqs) => (StatusCode::OK, Json(serde_json::json!({
|
|
"data": reqs,
|
|
"pagination": { "page": page, "limit": limit }
|
|
}))).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn create_requirement(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<CreateRequirementRequest>,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
if customer.status != "APPROVED" {
|
|
return (StatusCode::FORBIDDEN, "Customer profile approval is required before posting requirements").into_response();
|
|
}
|
|
|
|
if customer.active_requirement_count >= 2 {
|
|
return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response();
|
|
}
|
|
|
|
let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let db_payload = DbCreateRequirementPayload {
|
|
customer_id: customer.id,
|
|
profession_key: payload.profession_key,
|
|
title: payload.title,
|
|
description: payload.description,
|
|
location: payload.location,
|
|
budget: payload.budget,
|
|
preferred_date: p_date,
|
|
extra_data_json: payload.extra_data_json,
|
|
};
|
|
|
|
match RequirementRepository::create(&state.pool, db_payload).await {
|
|
Ok(req) => {
|
|
let _ = CustomerRepository::update_active_requirement_count(&state.pool, customer.id, 1).await;
|
|
(StatusCode::CREATED, Json(req)).into_response()
|
|
},
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_requirement(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
_auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
match RequirementRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(req)) => (StatusCode::OK, Json(req)).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 update_requirement(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<DbUpdateRequirementPayload>,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(r)) if r.customer_id == customer.id => r,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
|
};
|
|
|
|
match RequirementRepository::update(&state.pool, req.id, payload).await {
|
|
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn submit_requirement(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
if customer.status != "APPROVED" {
|
|
return (StatusCode::FORBIDDEN, "Customer profile approval is required before submitting requirements").into_response();
|
|
}
|
|
|
|
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(r)) if r.customer_id == customer.id => r,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
|
};
|
|
|
|
if req.status != "DRAFT" {
|
|
return (StatusCode::BAD_REQUEST, "Requirement already submitted or closed").into_response();
|
|
}
|
|
|
|
match RequirementRepository::update_status(&state.pool, req.id, "PENDING_APPROVAL").await {
|
|
Ok(updated) => {
|
|
// Fire email to customer (ignore failures)
|
|
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
|
|
let _ = state.mail.send_requirement_submitted_email(&user.email, user.full_name.as_deref().unwrap_or("User"), &updated.title).await;
|
|
}
|
|
|
|
// Create verification case so this request enters Verification Management first.
|
|
let verification_payload = serde_json::json!({
|
|
"entity_type": "REQUIREMENT",
|
|
"entity_id": updated.id,
|
|
"title": updated.title,
|
|
"profession_key": updated.profession_key,
|
|
"location": updated.location,
|
|
"budget": updated.budget,
|
|
"status": updated.status,
|
|
"customer_id": updated.customer_id,
|
|
});
|
|
let _ = VerificationRepository::create(
|
|
&state.pool,
|
|
auth.user_id,
|
|
"CUSTOMER",
|
|
"REQUIREMENT_APPROVAL",
|
|
"MEDIUM",
|
|
verification_payload,
|
|
serde_json::json!([]),
|
|
)
|
|
.await;
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_requests(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(r)) if r.customer_id == customer.id => r,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
|
};
|
|
|
|
let page = q.page.unwrap_or(1);
|
|
let limit = q.limit.unwrap_or(20);
|
|
let offset = (page - 1) * limit;
|
|
|
|
#[derive(serde::Serialize, sqlx::FromRow)]
|
|
struct RichLeadReqForCustomer {
|
|
#[serde(flatten)]
|
|
#[sqlx(flatten)]
|
|
lead: db::models::lead_request::LeadRequest,
|
|
professional_name: Option<String>,
|
|
professional_avatar_url: Option<String>,
|
|
}
|
|
|
|
let rows_result = sqlx::query_as::<_, RichLeadReqForCustomer>(
|
|
r#"
|
|
SELECT lr.*, u.full_name as professional_name, u.avatar_url as professional_avatar_url
|
|
FROM lead_requests lr
|
|
LEFT JOIN professional_profiles pp ON pp.id = lr.professional_id
|
|
LEFT JOIN users u ON u.id = pp.user_id
|
|
WHERE lr.requirement_id = $1
|
|
ORDER BY lr.requested_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
"#
|
|
)
|
|
.bind(req.id)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(&state.pool)
|
|
.await;
|
|
|
|
match rows_result {
|
|
Ok(leads) => (StatusCode::OK, Json(serde_json::json!({
|
|
"data": leads,
|
|
"pagination": { "page": page, "limit": limit }
|
|
}))).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn approve_request(
|
|
State(state): State<AppState>,
|
|
Path((req_id, lead_id)): Path<(Uuid, Uuid)>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
let req = match RequirementRepository::get_by_id(&state.pool, req_id).await {
|
|
Ok(Some(r)) if r.customer_id == customer.id => r,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
|
};
|
|
|
|
let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await {
|
|
Ok(Some(l)) if l.requirement_id == req.id => l,
|
|
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
|
};
|
|
|
|
if lead.status != "PENDING" {
|
|
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
|
|
}
|
|
|
|
match LeadRequestRepository::update_status(&state.pool, lead.id, "ACCEPTED").await {
|
|
Ok(updated) => {
|
|
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await {
|
|
Ok(Some(user_id)) => user_id,
|
|
Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
match ProfessionalRepository::try_debit_reserved_tracecoins(
|
|
&state.pool,
|
|
prof_user_id,
|
|
lead.tracecoins_reserved,
|
|
lead.id,
|
|
).await {
|
|
Ok(true) => {}
|
|
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
|
|
let req_after = match RequirementRepository::increment_accepted_count_and_get(&state.pool, req.id).await {
|
|
Ok(r) => r,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
if req_after.accepted_count >= 10 && req_after.status != "CLOSED" {
|
|
let _ = RequirementRepository::update_status(&state.pool, req.id, "CLOSED").await;
|
|
}
|
|
|
|
// Send contact-exchange emails to both parties (ignore failures)
|
|
let customer_user = UserRepository::get_by_id(&state.pool, auth.user_id).await.ok();
|
|
let professional_user = UserRepository::get_by_id(&state.pool, prof_user_id).await.ok();
|
|
if let (Some(cust), Some(prof)) = (customer_user, professional_user) {
|
|
let cust_phone = cust.phone.as_deref().unwrap_or("N/A");
|
|
let prof_phone = prof.phone.as_deref().unwrap_or("N/A");
|
|
let _ = state.mail.send_lead_accepted_professional_email(
|
|
&prof.email, prof.full_name.as_deref().unwrap_or("Professional"), cust.full_name.as_deref().unwrap_or("Customer"), &cust.email, cust_phone,
|
|
).await;
|
|
let _ = state.mail.send_lead_accepted_customer_email(
|
|
&cust.email, cust.full_name.as_deref().unwrap_or("Customer"), prof.full_name.as_deref().unwrap_or("Professional"), &prof.email, prof_phone,
|
|
).await;
|
|
}
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"lead_request": updated,
|
|
"requirement_status": if req_after.accepted_count >= 10 { "CLOSED" } else { req_after.status.as_str() },
|
|
"accepted_count": req_after.accepted_count,
|
|
}))).into_response()
|
|
},
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn reject_request(
|
|
State(state): State<AppState>,
|
|
Path((req_id, lead_id)): Path<(Uuid, Uuid)>,
|
|
auth: AuthUser,
|
|
Json(_payload): Json<RejectRequestPayload>,
|
|
) -> impl IntoResponse {
|
|
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
|
};
|
|
|
|
let req = match RequirementRepository::get_by_id(&state.pool, req_id).await {
|
|
Ok(Some(r)) if r.customer_id == customer.id => r,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
|
};
|
|
|
|
let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await {
|
|
Ok(Some(l)) if l.requirement_id == req.id => l,
|
|
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
|
};
|
|
|
|
if lead.status != "PENDING" {
|
|
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
|
|
}
|
|
|
|
match LeadRequestRepository::update_status(&state.pool, lead.id, "REJECTED").await {
|
|
Ok(updated) => {
|
|
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await {
|
|
Ok(Some(user_id)) => user_id,
|
|
Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
match ProfessionalRepository::try_release_reserved_tracecoins(
|
|
&state.pool,
|
|
prof_user_id,
|
|
lead.tracecoins_reserved,
|
|
lead.id,
|
|
"LEAD_REJECTED",
|
|
).await {
|
|
Ok(true) => {}
|
|
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
|
|
// Notify professional their request was rejected (ignore failures)
|
|
if let Ok(prof_user) = UserRepository::get_by_id(&state.pool, prof_user_id).await {
|
|
let _ = state.mail.send_lead_rejected_email(
|
|
&prof_user.email, prof_user.full_name.as_deref().unwrap_or("Professional"), &req.title,
|
|
).await;
|
|
}
|
|
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
},
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|