feat(backend): enforce profile approvals and complete migration approval flows
This commit is contained in:
parent
9764a7acdd
commit
3b6d0f4951
19 changed files with 1690 additions and 58 deletions
10
.env.example
10
.env.example
|
|
@ -15,10 +15,18 @@ REFRESH_TOKEN_EXPIRY_DAYS=30
|
||||||
SMTP_HOST=smtp.zeptomail.in
|
SMTP_HOST=smtp.zeptomail.in
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=emailapikey
|
SMTP_USER=emailapikey
|
||||||
SMTP_PASS=PHtE6r1ZR+zi3jV88RNW4/O4F8CkPdksqO9iJAhA4YcTD6dQFk1S+dl/wDC3/h97AKYWFfSczo1rt72etOuDLTnrMjlEDWqyqK3sx/VYSPOZsbq6x00esVgYdEfYVYDpcNFj3SPQut7dNA==
|
SMTP_PASS=replace-with-your-smtp-password
|
||||||
SMTP_FROM_EMAIL=support@nxtgauge.com
|
SMTP_FROM_EMAIL=support@nxtgauge.com
|
||||||
SMTP_FROM_NAME=NXTGAUGE
|
SMTP_FROM_NAME=NXTGAUGE
|
||||||
|
|
||||||
|
# ── Object Storage (Backblaze B2 S3-Compatible) ─────────────────────────────
|
||||||
|
B2_BUCKET_NAME=Nxtgauge-object
|
||||||
|
B2_REGION=eu-central-003
|
||||||
|
B2_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
|
||||||
|
B2_ACCESS_KEY_ID=replace-with-b2-key-id
|
||||||
|
B2_SECRET_ACCESS_KEY=replace-with-b2-secret
|
||||||
|
B2_USE_PATH_STYLE=true
|
||||||
|
|
||||||
|
|
||||||
# ── Payments ──────────────────────────────────────────────────────────────────
|
# ── Payments ──────────────────────────────────────────────────────────────────
|
||||||
RAZORPAY_KEY_ID=rzp_test_...
|
RAZORPAY_KEY_ID=rzp_test_...
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,10 @@ async fn create_job(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if company.status != "APPROVED" {
|
||||||
|
return (StatusCode::FORBIDDEN, "Company profile approval is required before posting jobs").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let db_payload = DbCreateJobPayload {
|
let db_payload = DbCreateJobPayload {
|
||||||
company_id: company.id,
|
company_id: company.id,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
|
|
@ -145,7 +149,11 @@ async fn update_job(
|
||||||
Ok(Some(c)) => c,
|
Ok(Some(c)) => c,
|
||||||
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if company.status != "APPROVED" {
|
||||||
|
return (StatusCode::FORBIDDEN, "Company profile approval is required before submitting jobs").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let job = match JobRepository::get_by_id(&pool, id).await {
|
let job = match JobRepository::get_by_id(&pool, id).await {
|
||||||
Ok(Some(j)) if j.company_id == company.id => j,
|
Ok(Some(j)) if j.company_id == company.id => j,
|
||||||
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
||||||
|
|
@ -301,4 +309,3 @@ async fn view_contact(
|
||||||
"message": "Contact revealed"
|
"message": "Contact revealed"
|
||||||
}))).into_response()
|
}))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
|
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::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload};
|
||||||
use db::models::lead_request::LeadRequestRepository;
|
use db::models::lead_request::LeadRequestRepository;
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
|
|
@ -18,6 +19,7 @@ pub fn router() -> Router<PgPool> {
|
||||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||||
.route("/requirements", get(list_requirements).post(create_requirement))
|
.route("/requirements", get(list_requirements).post(create_requirement))
|
||||||
.route("/requirements/:id", get(get_requirement).patch(update_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", get(list_requests))
|
||||||
.route("/requirements/:id/requests/:lead_id/approve", post(approve_request))
|
.route("/requirements/:id/requests/:lead_id/approve", post(approve_request))
|
||||||
.route("/requirements/:id/requests/:lead_id/reject", post(reject_request))
|
.route("/requirements/:id/requests/:lead_id/reject", post(reject_request))
|
||||||
|
|
@ -98,6 +100,10 @@ async fn create_requirement(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
|
_ => 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 {
|
if customer.active_requirement_count >= 2 {
|
||||||
return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response();
|
return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response();
|
||||||
}
|
}
|
||||||
|
|
@ -214,9 +220,37 @@ async fn approve_request(
|
||||||
|
|
||||||
match LeadRequestRepository::update_status(&pool, lead.id, "ACCEPTED").await {
|
match LeadRequestRepository::update_status(&pool, lead.id, "ACCEPTED").await {
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
let _ = RequirementRepository::increment_accepted_count(&pool, req.id).await;
|
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&pool, lead.professional_id).await {
|
||||||
// TODO: Reveal contact to professional + final Tracecoin deduction logic
|
Ok(Some(user_id)) => user_id,
|
||||||
(StatusCode::OK, Json(updated)).into_response()
|
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(
|
||||||
|
&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(&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(&pool, req.id, "CLOSED").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(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
|
|
@ -250,10 +284,56 @@ async fn reject_request(
|
||||||
|
|
||||||
match LeadRequestRepository::update_status(&pool, lead.id, "REJECTED").await {
|
match LeadRequestRepository::update_status(&pool, lead.id, "REJECTED").await {
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
// TODO: Return reserved Tracecoins to professional
|
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&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(
|
||||||
|
&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(),
|
||||||
|
}
|
||||||
|
|
||||||
(StatusCode::OK, Json(updated)).into_response()
|
(StatusCode::OK, Json(updated)).into_response()
|
||||||
},
|
},
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn submit_requirement(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let customer = match CustomerRepository::get_by_user_id(&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(&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(&pool, req.id, "PENDING_APPROVAL").await {
|
||||||
|
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
574
apps/users/src/handlers/approvals.rs
Normal file
574
apps/users/src/handlers/approvals.rs
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
|
use db::models::job::JobRepository;
|
||||||
|
use db::models::requirement::RequirementRepository;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_pending))
|
||||||
|
.route("/profiles/company/:user_id/approve", post(approve_company_profile))
|
||||||
|
.route("/profiles/company/:user_id/reject", post(reject_company_profile))
|
||||||
|
.route("/profiles/customer/:user_id/approve", post(approve_customer_profile))
|
||||||
|
.route("/profiles/customer/:user_id/reject", post(reject_customer_profile))
|
||||||
|
.route("/profiles/professional/:role_key/:user_id/approve", post(approve_professional_profile))
|
||||||
|
.route("/profiles/professional/:role_key/:user_id/reject", post(reject_professional_profile))
|
||||||
|
.route("/jobs/:id/approve", post(approve_job))
|
||||||
|
.route("/jobs/:id/reject", post(reject_job))
|
||||||
|
.route("/requirements/:id/approve", post(approve_requirement))
|
||||||
|
.route("/requirements/:id/reject", post(reject_requirement))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RejectPayload {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_pending(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<ListQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = q.page.unwrap_or(1);
|
||||||
|
let limit = q.limit.unwrap_or(20);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let jobs = sqlx::query_as!(
|
||||||
|
db::models::job::Job,
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM jobs
|
||||||
|
WHERE status = 'PENDING_APPROVAL'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let requirements = sqlx::query_as!(
|
||||||
|
db::models::requirement::Requirement,
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM requirements
|
||||||
|
WHERE status = 'PENDING_APPROVAL'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let company_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM company_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let customer_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM customer_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let photographer_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM photographer_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let makeup_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM makeup_artist_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tutor_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM tutor_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let developer_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM developer_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let video_editor_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM video_editor_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let graphic_designer_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM graphic_designer_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let social_media_manager_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM social_media_manager_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let fitness_trainer_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM fitness_trainer_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let catering_profiles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, status, updated_at
|
||||||
|
FROM catering_service_profiles
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match (
|
||||||
|
jobs,
|
||||||
|
requirements,
|
||||||
|
company_profiles,
|
||||||
|
customer_profiles,
|
||||||
|
photographer_profiles,
|
||||||
|
makeup_profiles,
|
||||||
|
tutor_profiles,
|
||||||
|
developer_profiles,
|
||||||
|
video_editor_profiles,
|
||||||
|
graphic_designer_profiles,
|
||||||
|
social_media_manager_profiles,
|
||||||
|
fitness_trainer_profiles,
|
||||||
|
catering_profiles,
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
Ok(jobs),
|
||||||
|
Ok(requirements),
|
||||||
|
Ok(company_profiles),
|
||||||
|
Ok(customer_profiles),
|
||||||
|
Ok(photographer_profiles),
|
||||||
|
Ok(makeup_profiles),
|
||||||
|
Ok(tutor_profiles),
|
||||||
|
Ok(developer_profiles),
|
||||||
|
Ok(video_editor_profiles),
|
||||||
|
Ok(graphic_designer_profiles),
|
||||||
|
Ok(social_media_manager_profiles),
|
||||||
|
Ok(fitness_trainer_profiles),
|
||||||
|
Ok(catering_profiles),
|
||||||
|
) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"jobs": jobs,
|
||||||
|
"requirements": requirements,
|
||||||
|
"profiles": {
|
||||||
|
"company": company_profiles,
|
||||||
|
"customer": customer_profiles,
|
||||||
|
"photographer": photographer_profiles,
|
||||||
|
"makeup_artist": makeup_profiles,
|
||||||
|
"tutor": tutor_profiles,
|
||||||
|
"developer": developer_profiles,
|
||||||
|
"video_editor": video_editor_profiles,
|
||||||
|
"graphic_designer": graphic_designer_profiles,
|
||||||
|
"social_media_manager": social_media_manager_profiles,
|
||||||
|
"fitness_trainer": fitness_trainer_profiles,
|
||||||
|
"catering_services": catering_profiles
|
||||||
|
},
|
||||||
|
"pagination": { "page": page, "limit": limit }
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
(Err(e), _, _, _, _, _, _, _, _, _, _, _, _)
|
||||||
|
| (_, Err(e), _, _, _, _, _, _, _, _, _, _, _)
|
||||||
|
| (_, _, Err(e), _, _, _, _, _, _, _, _, _, _)
|
||||||
|
| (_, _, _, Err(e), _, _, _, _, _, _, _, _, _)
|
||||||
|
| (_, _, _, _, Err(e), _, _, _, _, _, _, _, _)
|
||||||
|
| (_, _, _, _, _, Err(e), _, _, _, _, _, _, _)
|
||||||
|
| (_, _, _, _, _, _, Err(e), _, _, _, _, _, _)
|
||||||
|
| (_, _, _, _, _, _, _, Err(e), _, _, _, _, _)
|
||||||
|
| (_, _, _, _, _, _, _, _, Err(e), _, _, _, _)
|
||||||
|
| (_, _, _, _, _, _, _, _, _, Err(e), _, _, _)
|
||||||
|
| (_, _, _, _, _, _, _, _, _, _, Err(e), _, _)
|
||||||
|
| (_, _, _, _, _, _, _, _, _, _, _, Err(e), _)
|
||||||
|
| (_, _, _, _, _, _, _, _, _, _, _, _, Err(e)) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_company_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
match sqlx::query!(
|
||||||
|
"UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => {
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "APPROVED" }))).into_response()
|
||||||
|
}
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_company_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
match sqlx::query!(
|
||||||
|
"UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => {
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "REJECTED" }))).into_response()
|
||||||
|
}
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_customer_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
match sqlx::query!(
|
||||||
|
"UPDATE customer_profiles SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => {
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "APPROVED" }))).into_response()
|
||||||
|
}
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_customer_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
match sqlx::query!(
|
||||||
|
"UPDATE customer_profiles SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => {
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "REJECTED" }))).into_response()
|
||||||
|
}
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn professional_profile_table(role_key: &str) -> Option<&'static str> {
|
||||||
|
match role_key {
|
||||||
|
"PHOTOGRAPHER" => Some("photographer_profiles"),
|
||||||
|
"MAKEUP_ARTIST" => Some("makeup_artist_profiles"),
|
||||||
|
"TUTOR" => Some("tutor_profiles"),
|
||||||
|
"DEVELOPER" => Some("developer_profiles"),
|
||||||
|
"VIDEO_EDITOR" => Some("video_editor_profiles"),
|
||||||
|
"GRAPHIC_DESIGNER" => Some("graphic_designer_profiles"),
|
||||||
|
"SOCIAL_MEDIA_MANAGER" => Some("social_media_manager_profiles"),
|
||||||
|
"FITNESS_TRAINER" => Some("fitness_trainer_profiles"),
|
||||||
|
"CATERING_SERVICES" => Some("catering_service_profiles"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_professional_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((role_key, user_id)): Path<(String, Uuid)>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
let role_key = role_key.to_uppercase();
|
||||||
|
let Some(table) = professional_profile_table(&role_key) else {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Unsupported professional role_key").into_response();
|
||||||
|
};
|
||||||
|
let query = format!(
|
||||||
|
"UPDATE {} SET status = 'APPROVED', rejection_reason = NULL, approved_at = NOW(), updated_at = NOW() WHERE user_id = $1",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
match sqlx::query(&query).bind(user_id).execute(&state.pool).await {
|
||||||
|
Ok(result) if result.rows_affected() > 0 => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({ "user_id": user_id, "role_key": role_key, "status": "APPROVED" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_professional_profile(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((role_key, user_id)): Path<(String, Uuid)>,
|
||||||
|
Json(payload): Json<RejectPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
let role_key = role_key.to_uppercase();
|
||||||
|
let Some(table) = professional_profile_table(&role_key) else {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Unsupported professional role_key").into_response();
|
||||||
|
};
|
||||||
|
let query = format!(
|
||||||
|
"UPDATE {} SET status = 'REJECTED', rejection_reason = $2, updated_at = NOW() WHERE user_id = $1",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
match sqlx::query(&query)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(payload.reason.unwrap_or_else(|| "Profile rejected".to_string()))
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({ "user_id": user_id, "role_key": role_key, "status": "REJECTED" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Ok(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_job(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = match JobRepository::get_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(job)) => job,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing.status != "PENDING_APPROVAL" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Job is not pending approval").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match JobRepository::approve(&state.pool, id, auth.user_id).await {
|
||||||
|
Ok(job) => (StatusCode::OK, Json(job)).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_job(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<RejectPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = match JobRepository::get_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(job)) => job,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing.status != "PENDING_APPROVAL" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Job is not pending approval").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match JobRepository::reject(&state.pool, id, payload.reason).await {
|
||||||
|
Ok(job) => (StatusCode::OK, Json(job)).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_requirement(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = match RequirementRepository::get_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(req)) => req,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing.status != "PENDING_APPROVAL" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Requirement is not pending approval").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match RequirementRepository::approve(&state.pool, id, auth.user_id).await {
|
||||||
|
Ok(req) => (StatusCode::OK, Json(req)).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_requirement(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<RejectPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = match RequirementRepository::get_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(req)) => req,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing.status != "PENDING_APPROVAL" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Requirement is not pending approval").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match RequirementRepository::reject(&state.pool, id, payload.reason).await {
|
||||||
|
Ok(req) => (StatusCode::OK, Json(req)).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::AppState;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/check-email", post(check_email))
|
||||||
.route("/register", post(register))
|
.route("/register", post(register))
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
|
|
@ -45,6 +46,11 @@ pub struct LoginPayload {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CheckEmailPayload {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct VerifyEmailPayload {
|
pub struct VerifyEmailPayload {
|
||||||
pub otp: String,
|
pub otp: String,
|
||||||
|
|
@ -116,6 +122,31 @@ fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResp
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// POST /api/auth/check-email
|
||||||
|
async fn check_email(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CheckEmailPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let email = payload.email.trim().to_lowercase();
|
||||||
|
if email.is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"exists": false,
|
||||||
|
"error": "Email is required"
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists = UserRepository::get_by_email(&state.pool, &email).await.is_ok();
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"exists": exists
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/auth/register
|
/// POST /api/auth/register
|
||||||
async fn register(
|
async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
pub mod approvals;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
|
pub mod user_roles;
|
||||||
|
|
|
||||||
141
apps/users/src/handlers/user_roles.rs
Normal file
141
apps/users/src/handlers/user_roles.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use contracts::auth_middleware::AuthUser;
|
||||||
|
use db::models::role::RoleRepository;
|
||||||
|
use db::models::user::UserRepository;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_my_roles))
|
||||||
|
.route("/register", post(register_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRolePayload {
|
||||||
|
pub role_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserRoleResponse {
|
||||||
|
pub role_key: String,
|
||||||
|
pub role_name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub approved_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_professional_role(role_key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
role_key,
|
||||||
|
"PHOTOGRAPHER"
|
||||||
|
| "MAKEUP_ARTIST"
|
||||||
|
| "TUTOR"
|
||||||
|
| "DEVELOPER"
|
||||||
|
| "VIDEO_EDITOR"
|
||||||
|
| "GRAPHIC_DESIGNER"
|
||||||
|
| "SOCIAL_MEDIA_MANAGER"
|
||||||
|
| "FITNESS_TRAINER"
|
||||||
|
| "CATERING_SERVICES"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_my_roles(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT r.key, r.name, ur.status, ur.approved_at
|
||||||
|
FROM user_roles ur
|
||||||
|
INNER JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = $1
|
||||||
|
ORDER BY ur.created_at ASC
|
||||||
|
"#,
|
||||||
|
auth.user_id
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mapped = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| UserRoleResponse {
|
||||||
|
role_key: r.key,
|
||||||
|
role_name: r.name,
|
||||||
|
status: r.status,
|
||||||
|
approved_at: r.approved_at,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(mapped)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_role(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RegisterRolePayload>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let role_key = payload.role_key.trim().to_uppercase();
|
||||||
|
if role_key.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "role_key is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = RoleRepository::get_by_key(&state.pool, &role_key)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
||||||
|
VALUES ($1, $2, 'APPROVED', NOW())
|
||||||
|
ON CONFLICT (user_id, role_id)
|
||||||
|
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
||||||
|
"#,
|
||||||
|
auth.user_id,
|
||||||
|
role.id
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if is_professional_role(&role_key) {
|
||||||
|
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let display_name = user
|
||||||
|
.full_name
|
||||||
|
.unwrap_or_else(|| user.email.split('@').next().unwrap_or("Professional").to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO professionals (user_id, profession_key, display_name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET profession_key = EXCLUDED.profession_key, display_name = EXCLUDED.display_name, updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.bind(role_key.clone())
|
||||||
|
.bind(display_name)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"message": "Role registered successfully",
|
||||||
|
"role_key": role_key,
|
||||||
|
"role_id": role.id,
|
||||||
|
"user_id": auth.user_id.to_string(),
|
||||||
|
"status": "APPROVED"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -56,8 +56,11 @@ async fn main() {
|
||||||
.nest("/api/auth", handlers::auth::router())
|
.nest("/api/auth", handlers::auth::router())
|
||||||
// ── Roles & User Self-Service ─────────────────────────────────────
|
// ── Roles & User Self-Service ─────────────────────────────────────
|
||||||
.nest("/api/admin/roles", handlers::roles::router())
|
.nest("/api/admin/roles", handlers::roles::router())
|
||||||
|
.nest("/api/me/roles", handlers::user_roles::router())
|
||||||
// ── Notifications ─────────────────────────────────────────────────
|
// ── Notifications ─────────────────────────────────────────────────
|
||||||
.nest("/api/me/notifications", handlers::notifications::router())
|
.nest("/api/me/notifications", handlers::notifications::router())
|
||||||
|
// ── Admin: Approvals (jobs/requirements) ─────────────────────────
|
||||||
|
.nest("/api/admin/approvals", handlers::approvals::router())
|
||||||
// ── Me: Profile Status ─────────────────────────────────────────────
|
// ── Me: Profile Status ─────────────────────────────────────────────
|
||||||
.nest("/api/me", handlers::onboarding::me_router())
|
.nest("/api/me", handlers::onboarding::me_router())
|
||||||
// ── Onboarding State (user-facing) ────────────────────────────────
|
// ── Onboarding State (user-facing) ────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@ use chrono::Utc;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
|
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
|
||||||
use db::models::professional::ProfessionalRepository;
|
use db::models::professional::{
|
||||||
|
CreatePortfolioItemPayload,
|
||||||
|
CreateServicePayload,
|
||||||
|
ProfessionalRepository,
|
||||||
|
UpdatePortfolioItemPayload,
|
||||||
|
UpdateServicePayload,
|
||||||
|
};
|
||||||
use db::models::requirement::RequirementRepository;
|
use db::models::requirement::RequirementRepository;
|
||||||
use crate::auth_middleware::AuthUser;
|
use crate::auth_middleware::AuthUser;
|
||||||
use crate::ProfessionState;
|
use crate::ProfessionState;
|
||||||
|
|
@ -66,7 +72,14 @@ pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
|
||||||
)
|
)
|
||||||
.route("/marketplace/:id", get(get_requirement))
|
.route("/marketplace/:id", get(get_requirement))
|
||||||
// ── Lead Requests ────────────────────────────────────────────────────
|
// ── Lead Requests ────────────────────────────────────────────────────
|
||||||
.route("/leads/request", post(send_lead_request))
|
.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/me", get(my_requests))
|
||||||
.route("/leads/requests/:id", delete(cancel_request))
|
.route("/leads/requests/:id", delete(cancel_request))
|
||||||
.route("/leads/accepted/me", get(accepted_leads))
|
.route("/leads/accepted/me", get(accepted_leads))
|
||||||
|
|
@ -103,6 +116,7 @@ async fn send_lead_request(
|
||||||
State(state): State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(payload): Json<LeadRequestPayload>,
|
Json(payload): Json<LeadRequestPayload>,
|
||||||
|
profession_key: &'static str,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut redis = state.redis.clone();
|
let mut redis = state.redis.clone();
|
||||||
|
|
||||||
|
|
@ -119,6 +133,18 @@ async fn send_lead_request(
|
||||||
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
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) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
|
||||||
// ── Deduplication: one lead per requirement per professional (24 h) ────────
|
// ── Deduplication: one lead per requirement per professional (24 h) ────────
|
||||||
let duplicate = cache::lead::is_duplicate(
|
let duplicate = cache::lead::is_duplicate(
|
||||||
&mut redis,
|
&mut redis,
|
||||||
|
|
@ -159,6 +185,26 @@ async fn send_lead_request(
|
||||||
|
|
||||||
match LeadRequestRepository::create(&state.pool, db_payload).await {
|
match LeadRequestRepository::create(&state.pool, db_payload).await {
|
||||||
Ok(lead) => {
|
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;
|
let _ = RequirementRepository::increment_request_count(&state.pool, req.id).await;
|
||||||
// Mark dedup in Redis so this professional can't spam the same requirement
|
// Mark dedup in Redis so this professional can't spam the same requirement
|
||||||
let _ = cache::lead::mark_sent(
|
let _ = cache::lead::mark_sent(
|
||||||
|
|
@ -179,6 +225,72 @@ async fn send_lead_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
async fn list_portfolio(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||||
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||||
Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await {
|
Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await {
|
||||||
|
|
@ -241,75 +353,149 @@ async fn accepted_lead_detail(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_portfolio_item(
|
async fn create_portfolio_item(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_p: Json<serde_json::Value>,
|
Json(payload): Json<CreatePortfolioItemPayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() })))
|
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(
|
async fn update_portfolio_item(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_id: Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
_p: Json<serde_json::Value>,
|
Json(payload): Json<UpdatePortfolioItemPayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
|
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(
|
async fn delete_portfolio_item(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_id: Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
|
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(
|
async fn create_service(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_p: Json<serde_json::Value>,
|
Json(payload): Json<CreateServicePayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() })))
|
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(
|
async fn update_service(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_id: Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
_p: Json<serde_json::Value>,
|
Json(payload): Json<UpdateServicePayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
|
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(
|
async fn delete_service(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_id: Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
|
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(
|
async fn wallet_ledger(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_q: Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
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(
|
async fn wallet_invoices(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
_q: Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
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(
|
async fn wallet_invoice_detail(
|
||||||
_s: State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
_a: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
|
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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger;
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger;
|
||||||
|
DROP FUNCTION IF EXISTS prevent_tracecoin_ledger_mutation();
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Enforce immutable tracecoin ledger: no UPDATE/DELETE allowed.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_tracecoin_ledger_mutation()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'tracecoin_ledger is immutable; % is not allowed', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger;
|
||||||
|
CREATE TRIGGER trg_prevent_tracecoin_ledger_update
|
||||||
|
BEFORE UPDATE ON tracecoin_ledger
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_tracecoin_ledger_mutation();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger;
|
||||||
|
CREATE TRIGGER trg_prevent_tracecoin_ledger_delete
|
||||||
|
BEFORE DELETE ON tracecoin_ledger
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_tracecoin_ledger_mutation();
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
UPDATE company_profiles
|
||||||
|
SET status = 'ACTIVE'
|
||||||
|
WHERE status = 'APPROVED';
|
||||||
|
|
||||||
|
UPDATE customer_profiles
|
||||||
|
SET status = 'ACTIVE'
|
||||||
|
WHERE status = 'APPROVED';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
UPDATE company_profiles
|
||||||
|
SET status = 'APPROVED'
|
||||||
|
WHERE status = 'ACTIVE';
|
||||||
|
|
||||||
|
UPDATE customer_profiles
|
||||||
|
SET status = 'APPROVED'
|
||||||
|
WHERE status = 'ACTIVE';
|
||||||
|
|
@ -88,9 +88,9 @@ impl CompanyRepository {
|
||||||
INSERT INTO company_profiles (
|
INSERT INTO company_profiles (
|
||||||
user_id, company_name, registration_number, industry, website_url,
|
user_id, company_name, registration_number, industry, website_url,
|
||||||
employee_count, business_type, gst_number, contact_name,
|
employee_count, business_type, gst_number, contact_name,
|
||||||
contact_email, contact_phone, address_line1, city, state, postal_code
|
contact_email, contact_phone, address_line1, city, state, postal_code, status
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'PENDING')
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
company_name = EXCLUDED.company_name,
|
company_name = EXCLUDED.company_name,
|
||||||
registration_number = EXCLUDED.registration_number,
|
registration_number = EXCLUDED.registration_number,
|
||||||
|
|
@ -106,6 +106,10 @@ impl CompanyRepository {
|
||||||
city = EXCLUDED.city,
|
city = EXCLUDED.city,
|
||||||
state = EXCLUDED.state,
|
state = EXCLUDED.state,
|
||||||
postal_code = EXCLUDED.postal_code,
|
postal_code = EXCLUDED.postal_code,
|
||||||
|
status = CASE
|
||||||
|
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
|
||||||
|
ELSE 'PENDING'
|
||||||
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING
|
RETURNING
|
||||||
id, user_id, company_name, registration_number, industry,
|
id, user_id, company_name, registration_number, industry,
|
||||||
|
|
@ -137,4 +141,3 @@ impl CompanyRepository {
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,9 @@ impl CustomerRepository {
|
||||||
CustomerProfile,
|
CustomerProfile,
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO customer_profiles (
|
INSERT INTO customer_profiles (
|
||||||
user_id, full_name, phone, city, area, preferred_professions, bio, custom_data
|
user_id, full_name, phone, city, area, preferred_professions, bio, custom_data, status
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING')
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
full_name = EXCLUDED.full_name,
|
full_name = EXCLUDED.full_name,
|
||||||
phone = EXCLUDED.phone,
|
phone = EXCLUDED.phone,
|
||||||
|
|
@ -77,6 +77,10 @@ impl CustomerRepository {
|
||||||
preferred_professions = EXCLUDED.preferred_professions,
|
preferred_professions = EXCLUDED.preferred_professions,
|
||||||
bio = EXCLUDED.bio,
|
bio = EXCLUDED.bio,
|
||||||
custom_data = EXCLUDED.custom_data,
|
custom_data = EXCLUDED.custom_data,
|
||||||
|
status = CASE
|
||||||
|
WHEN customer_profiles.status = 'APPROVED' THEN 'APPROVED'
|
||||||
|
ELSE 'PENDING'
|
||||||
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING
|
RETURNING
|
||||||
id, user_id, full_name, phone, city, area, preferred_professions,
|
id, user_id, full_name, phone, city, area, preferred_professions,
|
||||||
|
|
@ -113,4 +117,3 @@ impl CustomerRepository {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,4 +170,46 @@ impl JobRepository {
|
||||||
.await?;
|
.await?;
|
||||||
Ok(job)
|
Ok(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn approve(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
admin_user_id: Uuid,
|
||||||
|
) -> Result<Job, sqlx::Error> {
|
||||||
|
let job = sqlx::query_as!(
|
||||||
|
Job,
|
||||||
|
r#"
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'LIVE', approved_at = NOW(), approved_by = $1, rejection_reason = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
admin_user_id,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> Result<Job, sqlx::Error> {
|
||||||
|
let job = sqlx::query_as!(
|
||||||
|
Job,
|
||||||
|
r#"
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'REJECTED', rejection_reason = $1, approved_at = NULL, approved_by = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
reason,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(job)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,62 @@ pub struct Wallet {
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct TracecoinLedgerEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub wallet_id: Uuid,
|
||||||
|
pub r#type: String,
|
||||||
|
pub amount: i32,
|
||||||
|
pub reason: String,
|
||||||
|
pub reference_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Invoice {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub payment_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub invoice_number: String,
|
||||||
|
pub subtotal: i32,
|
||||||
|
pub gst_amount: i32,
|
||||||
|
pub total: i32,
|
||||||
|
pub status: String,
|
||||||
|
pub issued_at: DateTime<Utc>,
|
||||||
|
pub file_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreatePortfolioItemPayload {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdatePortfolioItemPayload {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateServicePayload {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub price: i32,
|
||||||
|
pub duration_minutes: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateServicePayload {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub price: Option<i32>,
|
||||||
|
pub duration_minutes: Option<i32>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProfessionalRepository;
|
pub struct ProfessionalRepository;
|
||||||
|
|
||||||
impl ProfessionalRepository {
|
impl ProfessionalRepository {
|
||||||
|
|
@ -117,4 +173,388 @@ impl ProfessionalRepository {
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
||||||
|
VALUES ($1, 0, 0)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_id_by_professional_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
) -> Result<Option<Uuid>, sqlx::Error> {
|
||||||
|
let row = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"SELECT user_id FROM professionals WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(professional_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_portfolio_item(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
payload: CreatePortfolioItemPayload,
|
||||||
|
) -> Result<PortfolioItem, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, PortfolioItem>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO portfolio_items (professional_id, title, description, tags)
|
||||||
|
VALUES ($1, $2, $3, COALESCE($4::text[], '{}'))
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(professional_id)
|
||||||
|
.bind(payload.title)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(payload.tags)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_portfolio_item(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
payload: UpdatePortfolioItemPayload,
|
||||||
|
) -> Result<Option<PortfolioItem>, sqlx::Error> {
|
||||||
|
let row = sqlx::query_as::<_, PortfolioItem>(
|
||||||
|
r#"
|
||||||
|
UPDATE portfolio_items
|
||||||
|
SET
|
||||||
|
title = COALESCE($1, title),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
tags = COALESCE($3, tags),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4 AND professional_id = $5
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.title)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(payload.tags)
|
||||||
|
.bind(id)
|
||||||
|
.bind(professional_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_portfolio_item(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM portfolio_items WHERE id = $1 AND professional_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(professional_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_service(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
payload: CreateServicePayload,
|
||||||
|
) -> Result<Service, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Service>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO services (professional_id, name, description, price, duration_minutes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(professional_id)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(payload.price)
|
||||||
|
.bind(payload.duration_minutes)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_service(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
payload: UpdateServicePayload,
|
||||||
|
) -> Result<Option<Service>, sqlx::Error> {
|
||||||
|
let row = sqlx::query_as::<_, Service>(
|
||||||
|
r#"
|
||||||
|
UPDATE services
|
||||||
|
SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
price = COALESCE($3, price),
|
||||||
|
duration_minutes = COALESCE($4, duration_minutes),
|
||||||
|
is_active = COALESCE($5, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $6 AND professional_id = $7
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(payload.price)
|
||||||
|
.bind(payload.duration_minutes)
|
||||||
|
.bind(payload.is_active)
|
||||||
|
.bind(id)
|
||||||
|
.bind(professional_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_service(
|
||||||
|
pool: &PgPool,
|
||||||
|
professional_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query("DELETE FROM services WHERE id = $1 AND professional_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(professional_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_wallet_ledger(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<TracecoinLedgerEntry>, sqlx::Error> {
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
sqlx::query_as::<_, TracecoinLedgerEntry>(
|
||||||
|
r#"
|
||||||
|
SELECT l.*
|
||||||
|
FROM tracecoin_ledger l
|
||||||
|
INNER JOIN tracecoin_wallets w ON w.id = l.wallet_id
|
||||||
|
WHERE w.user_id = $1
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_wallet_invoices(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<Invoice>, sqlx::Error> {
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
sqlx::query_as::<_, Invoice>(
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM invoices
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY issued_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invoice_by_id_for_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<Invoice>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Invoice>(
|
||||||
|
"SELECT * FROM invoices WHERE id = $1 AND user_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_reserve_tracecoins(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
reference_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
||||||
|
VALUES ($1, 0, 0)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let wallet = sqlx::query_as::<_, Wallet>(
|
||||||
|
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if wallet.balance < amount {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE tracecoin_wallets
|
||||||
|
SET balance = balance - $1, reserved = reserved + $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
|
||||||
|
VALUES ($1, 'RESERVE', $2, 'LEAD_REQUEST', $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(reference_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_debit_reserved_tracecoins(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
reference_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let wallet = sqlx::query_as::<_, Wallet>(
|
||||||
|
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(wallet) = wallet else {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if wallet.reserved < amount {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE tracecoin_wallets
|
||||||
|
SET reserved = reserved - $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
|
||||||
|
VALUES ($1, 'DEBIT', $2, 'LEAD_ACCEPTED', $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(reference_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_release_reserved_tracecoins(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
reference_id: Uuid,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let wallet = sqlx::query_as::<_, Wallet>(
|
||||||
|
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(wallet) = wallet else {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if wallet.reserved < amount {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE tracecoin_wallets
|
||||||
|
SET reserved = reserved - $1, balance = balance + $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
|
||||||
|
VALUES ($1, 'RELEASE', $2, $3, $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(reference_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,4 +175,62 @@ impl RequirementRepository {
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn increment_accepted_count_and_get(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Requirement, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Requirement,
|
||||||
|
r#"
|
||||||
|
UPDATE requirements
|
||||||
|
SET accepted_count = accepted_count + 1, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn approve(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
admin_user_id: Uuid,
|
||||||
|
) -> Result<Requirement, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Requirement,
|
||||||
|
r#"
|
||||||
|
UPDATE requirements
|
||||||
|
SET status = 'OPEN', approved_at = NOW(), approved_by = $1, rejection_reason = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
admin_user_id,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> Result<Requirement, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Requirement,
|
||||||
|
r#"
|
||||||
|
UPDATE requirements
|
||||||
|
SET status = 'REJECTED', rejection_reason = $1, approved_at = NULL, approved_by = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
reason,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
//! Backblaze B2 file storage via S3-compatible API.
|
//! Backblaze B2 file storage via S3-compatible API.
|
||||||
//!
|
//!
|
||||||
//! Configuration (environment variables):
|
//! Configuration (environment variables):
|
||||||
//! B2_KEY_ID — Application Key ID
|
//! B2_ACCESS_KEY_ID — Application Key ID (preferred)
|
||||||
//! B2_APPLICATION_KEY — Application Key secret
|
//! B2_SECRET_ACCESS_KEY — Application Key secret (preferred)
|
||||||
|
//! B2_KEY_ID — Legacy alias for access key ID
|
||||||
|
//! B2_APPLICATION_KEY — Legacy alias for secret key
|
||||||
//! B2_BUCKET_NAME — Bucket name (e.g. Nxtgauge-object)
|
//! B2_BUCKET_NAME — Bucket name (e.g. Nxtgauge-object)
|
||||||
//! B2_ENDPOINT — S3 endpoint (e.g. s3.eu-central-003.backblazeb2.com)
|
//! B2_ENDPOINT — S3 endpoint (e.g. s3.eu-central-003.backblazeb2.com)
|
||||||
//! B2_REGION — Region (e.g. eu-central-003)
|
//! B2_REGION — Region (e.g. eu-central-003)
|
||||||
|
//! B2_USE_PATH_STYLE — true/false (default true)
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use aws_config::Region;
|
use aws_config::Region;
|
||||||
|
|
@ -24,13 +27,27 @@ pub struct StorageClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageClient {
|
impl StorageClient {
|
||||||
|
fn env_required(primary: &str, legacy: &str) -> String {
|
||||||
|
std::env::var(primary)
|
||||||
|
.or_else(|_| std::env::var(legacy))
|
||||||
|
.unwrap_or_else(|_| panic!("{} (or {}) must be set", primary, legacy))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build from environment variables. Panics if required vars are missing.
|
/// Build from environment variables. Panics if required vars are missing.
|
||||||
pub async fn from_env() -> Self {
|
pub async fn from_env() -> Self {
|
||||||
let key_id = std::env::var("B2_KEY_ID").expect("B2_KEY_ID must be set");
|
let key_id = Self::env_required("B2_ACCESS_KEY_ID", "B2_KEY_ID");
|
||||||
let app_key = std::env::var("B2_APPLICATION_KEY").expect("B2_APPLICATION_KEY must be set");
|
let app_key = Self::env_required("B2_SECRET_ACCESS_KEY", "B2_APPLICATION_KEY");
|
||||||
let bucket = std::env::var("B2_BUCKET_NAME").expect("B2_BUCKET_NAME must be set");
|
let bucket = std::env::var("B2_BUCKET_NAME").expect("B2_BUCKET_NAME must be set");
|
||||||
let endpoint = std::env::var("B2_ENDPOINT").expect("B2_ENDPOINT must be set");
|
let endpoint = std::env::var("B2_ENDPOINT")
|
||||||
|
.expect("B2_ENDPOINT must be set")
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.to_string();
|
||||||
let region = std::env::var("B2_REGION").expect("B2_REGION must be set");
|
let region = std::env::var("B2_REGION").expect("B2_REGION must be set");
|
||||||
|
let use_path_style = std::env::var("B2_USE_PATH_STYLE")
|
||||||
|
.ok()
|
||||||
|
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y"))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
let creds = Credentials::new(key_id, app_key, None, None, "nxtgauge-storage");
|
let creds = Credentials::new(key_id, app_key, None, None, "nxtgauge-storage");
|
||||||
let endpoint_url = format!("https://{}", endpoint);
|
let endpoint_url = format!("https://{}", endpoint);
|
||||||
|
|
@ -40,7 +57,7 @@ impl StorageClient {
|
||||||
.endpoint_url(endpoint_url)
|
.endpoint_url(endpoint_url)
|
||||||
.region(Region::new(region))
|
.region(Region::new(region))
|
||||||
.credentials_provider(SharedCredentialsProvider::new(creds))
|
.credentials_provider(SharedCredentialsProvider::new(creds))
|
||||||
.force_path_style(true)
|
.force_path_style(use_path_style)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let client = Client::from_conf(s3_config);
|
let client = Client::from_conf(s3_config);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue