feat(backend): enforce profile approvals and complete migration approval flows

This commit is contained in:
Ashwin Kumar 2026-03-19 00:30:23 +01:00
parent 9764a7acdd
commit 3b6d0f4951
19 changed files with 1690 additions and 58 deletions

View file

@ -15,10 +15,18 @@ REFRESH_TOKEN_EXPIRY_DAYS=30
SMTP_HOST=smtp.zeptomail.in
SMTP_PORT=587
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_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 ──────────────────────────────────────────────────────────────────
RAZORPAY_KEY_ID=rzp_test_...

View file

@ -103,6 +103,10 @@ async fn create_job(
_ => 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 {
company_id: company.id,
title: payload.title,
@ -145,7 +149,11 @@ async fn update_job(
Ok(Some(c)) => c,
_ => 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 {
Ok(Some(j)) if j.company_id == company.id => j,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
@ -301,4 +309,3 @@ async fn view_contact(
"message": "Contact revealed"
}))).into_response()
}

View file

@ -9,6 +9,7 @@ use serde::Deserialize;
use sqlx::PgPool;
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 contracts::auth_middleware::AuthUser;
@ -18,6 +19,7 @@ pub fn router() -> Router<PgPool> {
.route("/profile/me", get(get_profile).patch(update_profile))
.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))
@ -98,6 +100,10 @@ async fn create_requirement(
_ => 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();
}
@ -214,9 +220,37 @@ async fn approve_request(
match LeadRequestRepository::update_status(&pool, lead.id, "ACCEPTED").await {
Ok(updated) => {
let _ = RequirementRepository::increment_accepted_count(&pool, req.id).await;
// TODO: Reveal contact to professional + final Tracecoin deduction logic
(StatusCode::OK, Json(updated)).into_response()
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_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(),
}
@ -250,10 +284,56 @@ async fn reject_request(
match LeadRequestRepository::update_status(&pool, lead.id, "REJECTED").await {
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()
},
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(),
}
}

View 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(),
}
}

View file

@ -16,6 +16,7 @@ use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/check-email", post(check_email))
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
@ -45,6 +46,11 @@ pub struct LoginPayload {
pub password: String,
}
#[derive(Deserialize)]
pub struct CheckEmailPayload {
pub email: String,
}
#[derive(Deserialize)]
pub struct VerifyEmailPayload {
pub otp: String,
@ -116,6 +122,31 @@ fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResp
// ── 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
async fn register(
State(state): State<AppState>,

View file

@ -1,5 +1,7 @@
pub mod approvals;
pub mod auth;
pub mod config;
pub mod notifications;
pub mod onboarding;
pub mod roles;
pub mod user_roles;

View 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"
})),
))
}

View file

@ -56,8 +56,11 @@ async fn main() {
.nest("/api/auth", handlers::auth::router())
// ── Roles & User Self-Service ─────────────────────────────────────
.nest("/api/admin/roles", handlers::roles::router())
.nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ─────────────────────────────────────────────────
.nest("/api/me/notifications", handlers::notifications::router())
// ── Admin: Approvals (jobs/requirements) ─────────────────────────
.nest("/api/admin/approvals", handlers::approvals::router())
// ── Me: Profile Status ─────────────────────────────────────────────
.nest("/api/me", handlers::onboarding::me_router())
// ── Onboarding State (user-facing) ────────────────────────────────

View file

@ -9,7 +9,13 @@ use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
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 crate::auth_middleware::AuthUser;
use crate::ProfessionState;
@ -66,7 +72,14 @@ pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
)
.route("/marketplace/:id", get(get_requirement))
// ── 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/:id", delete(cancel_request))
.route("/leads/accepted/me", get(accepted_leads))
@ -103,6 +116,7 @@ async fn send_lead_request(
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<LeadRequestPayload>,
profession_key: &'static str,
) -> impl IntoResponse {
let mut redis = state.redis.clone();
@ -119,6 +133,18 @@ async fn send_lead_request(
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) ────────
let duplicate = cache::lead::is_duplicate(
&mut redis,
@ -159,6 +185,26 @@ async fn send_lead_request(
match LeadRequestRepository::create(&state.pool, db_payload).await {
Ok(lead) => {
let reserved = ProfessionalRepository::try_reserve_tracecoins(
&state.pool,
auth.user_id,
lead.tracecoins_reserved,
lead.id,
)
.await;
match reserved {
Ok(true) => {}
Ok(false) => {
let _ = LeadRequestRepository::update_status(&state.pool, lead.id, "CANCELLED").await;
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
}
Err(e) => {
let _ = LeadRequestRepository::update_status(&state.pool, lead.id, "CANCELLED").await;
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
}
let _ = RequirementRepository::increment_request_count(&state.pool, req.id).await;
// Mark dedup in Redis so this professional can't spam the same requirement
let _ = cache::lead::mark_sent(
@ -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 {
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_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(
_s: State<ProfessionState>,
_a: AuthUser,
_p: Json<serde_json::Value>,
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<CreatePortfolioItemPayload>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
_p: Json<serde_json::Value>,
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePortfolioItemPayload>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_p: Json<serde_json::Value>,
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<CreateServicePayload>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
_p: Json<serde_json::Value>,
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateServicePayload>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
State(state): State<ProfessionState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
State(state): State<ProfessionState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> 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(
_s: State<ProfessionState>,
_a: AuthUser,
State(state): State<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> 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(),
}
}

View file

@ -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();

View file

@ -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();

View file

@ -0,0 +1,7 @@
UPDATE company_profiles
SET status = 'ACTIVE'
WHERE status = 'APPROVED';
UPDATE customer_profiles
SET status = 'ACTIVE'
WHERE status = 'APPROVED';

View file

@ -0,0 +1,7 @@
UPDATE company_profiles
SET status = 'APPROVED'
WHERE status = 'ACTIVE';
UPDATE customer_profiles
SET status = 'APPROVED'
WHERE status = 'ACTIVE';

View file

@ -88,9 +88,9 @@ impl CompanyRepository {
INSERT INTO company_profiles (
user_id, company_name, registration_number, industry, website_url,
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
company_name = EXCLUDED.company_name,
registration_number = EXCLUDED.registration_number,
@ -106,6 +106,10 @@ impl CompanyRepository {
city = EXCLUDED.city,
state = EXCLUDED.state,
postal_code = EXCLUDED.postal_code,
status = CASE
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
ELSE 'PENDING'
END,
updated_at = NOW()
RETURNING
id, user_id, company_name, registration_number, industry,
@ -137,4 +141,3 @@ impl CompanyRepository {
Ok(profile)
}
}

View file

@ -66,9 +66,9 @@ impl CustomerRepository {
CustomerProfile,
r#"
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
full_name = EXCLUDED.full_name,
phone = EXCLUDED.phone,
@ -77,6 +77,10 @@ impl CustomerRepository {
preferred_professions = EXCLUDED.preferred_professions,
bio = EXCLUDED.bio,
custom_data = EXCLUDED.custom_data,
status = CASE
WHEN customer_profiles.status = 'APPROVED' THEN 'APPROVED'
ELSE 'PENDING'
END,
updated_at = NOW()
RETURNING
id, user_id, full_name, phone, city, area, preferred_professions,
@ -113,4 +117,3 @@ impl CustomerRepository {
Ok(())
}
}

View file

@ -170,4 +170,46 @@ impl JobRepository {
.await?;
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)
}
}

View file

@ -52,6 +52,62 @@ pub struct Wallet {
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;
impl ProfessionalRepository {
@ -117,4 +173,388 @@ impl ProfessionalRepository {
.fetch_one(pool)
.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)
}
}

View file

@ -175,4 +175,62 @@ impl RequirementRepository {
.await?;
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
}
}

View file

@ -1,11 +1,14 @@
//! Backblaze B2 file storage via S3-compatible API.
//!
//! Configuration (environment variables):
//! B2_KEY_ID — Application Key ID
//! B2_APPLICATION_KEY — Application Key secret
//! B2_ACCESS_KEY_ID — Application Key ID (preferred)
//! 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_ENDPOINT — S3 endpoint (e.g. s3.eu-central-003.backblazeb2.com)
//! B2_REGION — Region (e.g. eu-central-003)
//! B2_USE_PATH_STYLE — true/false (default true)
use anyhow::{Context, Result};
use aws_config::Region;
@ -24,13 +27,27 @@ pub struct 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.
pub async fn from_env() -> Self {
let key_id = std::env::var("B2_KEY_ID").expect("B2_KEY_ID must be set");
let app_key = std::env::var("B2_APPLICATION_KEY").expect("B2_APPLICATION_KEY must be set");
let key_id = Self::env_required("B2_ACCESS_KEY_ID", "B2_KEY_ID");
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 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 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 endpoint_url = format!("https://{}", endpoint);
@ -40,7 +57,7 @@ impl StorageClient {
.endpoint_url(endpoint_url)
.region(Region::new(region))
.credentials_provider(SharedCredentialsProvider::new(creds))
.force_path_style(true)
.force_path_style(use_path_style)
.build();
let client = Client::from_conf(s3_config);