From 3b6d0f4951b1893b01a1bde30fdb7174a6175775 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Thu, 19 Mar 2026 00:30:23 +0100 Subject: [PATCH] feat(backend): enforce profile approvals and complete migration approval flows --- .env.example | 10 +- apps/companies/src/handlers.rs | 11 +- apps/customers/src/handlers.rs | 88 ++- apps/users/src/handlers/approvals.rs | 574 ++++++++++++++++++ apps/users/src/handlers/auth.rs | 31 + apps/users/src/handlers/mod.rs | 2 + apps/users/src/handlers/user_roles.rs | 141 +++++ apps/users/src/main.rs | 3 + crates/contracts/src/profession_shared.rs | 264 ++++++-- ...233000_tracecoin_ledger_immutable.down.sql | 3 + ...18233000_tracecoin_ledger_immutable.up.sql | 20 + ...kfill_active_profiles_to_approved.down.sql | 7 + ...ackfill_active_profiles_to_approved.up.sql | 7 + crates/db/src/models/company.rs | 9 +- crates/db/src/models/customer.rs | 9 +- crates/db/src/models/job.rs | 42 ++ crates/db/src/models/professional.rs | 440 ++++++++++++++ crates/db/src/models/requirement.rs | 58 ++ crates/storage/src/lib.rs | 29 +- 19 files changed, 1690 insertions(+), 58 deletions(-) create mode 100644 apps/users/src/handlers/approvals.rs create mode 100644 apps/users/src/handlers/user_roles.rs create mode 100644 crates/db/migrations/20260318233000_tracecoin_ledger_immutable.down.sql create mode 100644 crates/db/migrations/20260318233000_tracecoin_ledger_immutable.up.sql create mode 100644 crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.down.sql create mode 100644 crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.up.sql diff --git a/.env.example b/.env.example index 486ce7f..bf2d184 100644 --- a/.env.example +++ b/.env.example @@ -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_... diff --git a/apps/companies/src/handlers.rs b/apps/companies/src/handlers.rs index 174eee3..0cd1227 100644 --- a/apps/companies/src/handlers.rs +++ b/apps/companies/src/handlers.rs @@ -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() } - diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index c1a5446..aad1b3f 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -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 { .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, + Path(id): Path, + 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(), + } +} diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs new file mode 100644 index 0000000..7184541 --- /dev/null +++ b/apps/users/src/handlers/approvals.rs @@ -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 { + 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, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct RejectPayload { + pub reason: Option, +} + +async fn list_pending( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> 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, + Path(user_id): Path, +) -> 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, + Path(user_id): Path, +) -> 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, + Path(user_id): Path, +) -> 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, + Path(user_id): Path, +) -> 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, + 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, + Path((role_key, user_id)): Path<(String, Uuid)>, + Json(payload): Json, +) -> 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, + Path(id): Path, +) -> 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, + Path(id): Path, + Json(payload): Json, +) -> 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, + Path(id): Path, +) -> 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, + Path(id): Path, + Json(payload): Json, +) -> 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(), + } +} diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 498d8b3..17c29ae 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -16,6 +16,7 @@ use crate::AppState; pub fn router() -> Router { 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, + Json(payload): Json, +) -> 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, diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 9027575..674c9ea 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -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; diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs new file mode 100644 index 0000000..71aed8b --- /dev/null +++ b/apps/users/src/handlers/user_roles.rs @@ -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 { + 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>, +} + +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, +) -> Result { + 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::>(); + + Ok((StatusCode::OK, Json(mapped))) +} + +async fn register_role( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + 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" + })), + )) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index e978ea3..0a09cb6 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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) ──────────────────────────────── diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 513832b..8cf0cfb 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -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 { ) .route("/marketplace/:id", get(get_requirement)) // ── Lead Requests ──────────────────────────────────────────────────── - .route("/leads/request", post(send_lead_request)) + .route( + "/leads/request", + post( + move |state: State, auth: AuthUser, payload: Json| async move { + send_lead_request(state, auth, payload, profession_key).await + }, + ), + ) .route("/leads/requests/me", get(my_requests)) .route("/leads/requests/:id", delete(cancel_request)) .route("/leads/accepted/me", get(accepted_leads)) @@ -103,6 +116,7 @@ async fn send_lead_request( State(state): State, auth: AuthUser, Json(payload): Json, + profession_key: &'static str, ) -> impl IntoResponse { let mut redis = state.redis.clone(); @@ -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 { + let status = match profession_key { + "PHOTOGRAPHER" => { + sqlx::query_scalar::<_, String>("SELECT status FROM photographer_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "MAKEUP_ARTIST" => { + sqlx::query_scalar::<_, String>("SELECT status FROM makeup_artist_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "TUTOR" => { + sqlx::query_scalar::<_, String>("SELECT status FROM tutor_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "DEVELOPER" => { + sqlx::query_scalar::<_, String>("SELECT status FROM developer_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "VIDEO_EDITOR" => { + sqlx::query_scalar::<_, String>("SELECT status FROM video_editor_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "GRAPHIC_DESIGNER" => { + sqlx::query_scalar::<_, String>("SELECT status FROM graphic_designer_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "SOCIAL_MEDIA_MANAGER" => { + sqlx::query_scalar::<_, String>("SELECT status FROM social_media_manager_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "FITNESS_TRAINER" => { + sqlx::query_scalar::<_, String>("SELECT status FROM fitness_trainer_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + "CATERING_SERVICES" => { + sqlx::query_scalar::<_, String>("SELECT status FROM catering_service_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } + _ => None, + }; + + Ok(matches!(status.as_deref(), Some("APPROVED"))) +} + async fn list_portfolio(State(state): State, auth: AuthUser) -> impl IntoResponse { match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await { @@ -241,75 +353,149 @@ async fn accepted_lead_detail( } async fn create_portfolio_item( - _s: State, - _a: AuthUser, - _p: Json, + State(state): State, + auth: AuthUser, + Json(payload): Json, ) -> 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, - _a: AuthUser, - _id: Path, - _p: Json, + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(payload): Json, ) -> 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, - _a: AuthUser, - _id: Path, + State(state): State, + auth: AuthUser, + Path(id): Path, ) -> 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, - _a: AuthUser, - _p: Json, + State(state): State, + auth: AuthUser, + Json(payload): Json, ) -> 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, - _a: AuthUser, - _id: Path, - _p: Json, + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(payload): Json, ) -> 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, - _a: AuthUser, - _id: Path, + State(state): State, + auth: AuthUser, + Path(id): Path, ) -> 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, - _a: AuthUser, - _q: Query, + State(state): State, + auth: AuthUser, + Query(q): Query, ) -> 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, - _a: AuthUser, - _q: Query, + State(state): State, + auth: AuthUser, + Query(q): Query, ) -> 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, - _a: AuthUser, + State(state): State, + auth: AuthUser, Path(id): Path, ) -> 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(), + } } diff --git a/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.down.sql b/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.down.sql new file mode 100644 index 0000000..a56bbe9 --- /dev/null +++ b/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.down.sql @@ -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(); diff --git a/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.up.sql b/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.up.sql new file mode 100644 index 0000000..f557cf9 --- /dev/null +++ b/crates/db/migrations/20260318233000_tracecoin_ledger_immutable.up.sql @@ -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(); diff --git a/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.down.sql b/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.down.sql new file mode 100644 index 0000000..b1a6710 --- /dev/null +++ b/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.down.sql @@ -0,0 +1,7 @@ +UPDATE company_profiles +SET status = 'ACTIVE' +WHERE status = 'APPROVED'; + +UPDATE customer_profiles +SET status = 'ACTIVE' +WHERE status = 'APPROVED'; diff --git a/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.up.sql b/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.up.sql new file mode 100644 index 0000000..7f0bf1b --- /dev/null +++ b/crates/db/migrations/20260319090000_backfill_active_profiles_to_approved.up.sql @@ -0,0 +1,7 @@ +UPDATE company_profiles +SET status = 'APPROVED' +WHERE status = 'ACTIVE'; + +UPDATE customer_profiles +SET status = 'APPROVED' +WHERE status = 'ACTIVE'; diff --git a/crates/db/src/models/company.rs b/crates/db/src/models/company.rs index a7aff23..a160e55 100644 --- a/crates/db/src/models/company.rs +++ b/crates/db/src/models/company.rs @@ -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) } } - diff --git a/crates/db/src/models/customer.rs b/crates/db/src/models/customer.rs index f1c3845..4568fb5 100644 --- a/crates/db/src/models/customer.rs +++ b/crates/db/src/models/customer.rs @@ -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(()) } } - diff --git a/crates/db/src/models/job.rs b/crates/db/src/models/job.rs index 873cb68..61ec62a 100644 --- a/crates/db/src/models/job.rs +++ b/crates/db/src/models/job.rs @@ -170,4 +170,46 @@ impl JobRepository { .await?; Ok(job) } + + pub async fn approve( + pool: &PgPool, + id: Uuid, + admin_user_id: Uuid, + ) -> Result { + 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, + ) -> Result { + 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) + } } diff --git a/crates/db/src/models/professional.rs b/crates/db/src/models/professional.rs index 9d9e6c4..fc275d8 100644 --- a/crates/db/src/models/professional.rs +++ b/crates/db/src/models/professional.rs @@ -52,6 +52,62 @@ pub struct Wallet { pub updated_at: DateTime, } +#[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, + pub created_at: DateTime, +} + +#[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, + pub file_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreatePortfolioItemPayload { + pub title: String, + pub description: Option, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdatePortfolioItemPayload { + pub title: Option, + pub description: Option, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateServicePayload { + pub name: String, + pub description: Option, + pub price: i32, + pub duration_minutes: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateServicePayload { + pub name: Option, + pub description: Option, + pub price: Option, + pub duration_minutes: Option, + pub is_active: Option, +} + 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, 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 { + 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, 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 { + 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 { + 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, 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 { + 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, 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, 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, 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 { + 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 { + 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 { + 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) + } } diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index 0a6f817..22c6467 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -175,4 +175,62 @@ impl RequirementRepository { .await?; Ok(()) } + + pub async fn increment_accepted_count_and_get( + pool: &PgPool, + id: Uuid, + ) -> Result { + 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 { + 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, + ) -> Result { + 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 + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 7081298..63ccbc8 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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);