From cb53b68f49133349195a96028dc62e5b66fcef84 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Mon, 6 Apr 2026 06:19:10 +0200 Subject: [PATCH] feat: complete backend implementation - payments service, admin endpoints, auth guards, submit-for-verification for all roles - Add payments service proxying to Beeceptor mock gateway (create-order, verify, status) - Add companies admin approve/reject/suspend + get detail endpoints - Apply require_admin auth guards to all employee/department/designation handlers - Add submit-for-verification endpoint to all 12 roles (10 professions + job seekers + customers + companies) - Fix port conflict (employees moved from 8085 to 8096) - Add submit_for_verification methods to all profile repositories --- Cargo.toml | 3 +- apps/companies/src/handlers/admin.rs | 189 +++++++++++++++- apps/companies/src/handlers/mod.rs | 24 ++ apps/customers/src/handlers.rs | 24 ++ apps/employees/src/handlers/departments.rs | 12 + apps/employees/src/handlers/designations.rs | 17 +- apps/employees/src/handlers/employees.rs | 25 ++- apps/gateway/src/main.rs | 2 +- apps/job_seekers/src/handlers.rs | 24 ++ apps/payments/Cargo.toml | 14 ++ apps/payments/src/main.rs | 230 ++++++++++++++++++++ crates/contracts/src/profession_shared.rs | 23 ++ crates/db/src/models/company.rs | 26 +++ crates/db/src/models/customer.rs | 23 ++ crates/db/src/models/job_seeker.rs | 23 ++ crates/db/src/models/professional.rs | 32 +++ 16 files changed, 682 insertions(+), 9 deletions(-) create mode 100644 apps/payments/Cargo.toml create mode 100644 apps/payments/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 21ed9c7..1ccceb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,8 @@ members = [ "crates/cache", "crates/email", "apps/cron", - "apps/employees" + "apps/employees", + "apps/payments" ] [workspace.package] diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index 3eff55d..ad8d45f 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::get, + routing::{get, patch}, Json, Router, }; use contracts::auth_middleware::AuthUser; @@ -13,6 +13,10 @@ use uuid::Uuid; pub fn router() -> Router { Router::new() .route("/", get(list_companies)) + .route("/{id}", get(get_company)) + .route("/{id}/approve", patch(approve_company)) + .route("/{id}/reject", patch(reject_company)) + .route("/{id}/suspend", patch(suspend_company)) } #[derive(Deserialize)] @@ -32,6 +36,39 @@ pub struct AdminCompanyRow { pub updated_at: chrono::DateTime, } +#[derive(Serialize)] +pub struct AdminCompanyDetail { + pub id: Uuid, + pub user_id: Uuid, + pub company_name: String, + pub registration_number: Option, + pub industry: Option, + pub website_url: Option, + pub employee_count: Option, + pub business_type: Option, + pub gst_number: Option, + pub contact_name: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub address_line1: Option, + pub city: Option, + pub state: Option, + pub country: String, + pub postal_code: Option, + pub status: String, + pub free_job_slots: i32, + pub purchased_job_slots: i32, + pub free_contact_views: i32, + pub purchased_contact_views: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Deserialize)] +pub struct ApproveRejectRequest { + pub reason: Option, +} + async fn list_companies( _auth: AuthUser, State(state): State, @@ -58,3 +95,153 @@ async fn list_companies( Ok(Json(companies)) } + +async fn get_company( + _auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let company = sqlx::query_as!( + AdminCompanyDetail, + r#" + SELECT + id, 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, country, postal_code, status, + free_job_slots, purchased_job_slots, free_contact_views, purchased_contact_views, + created_at, updated_at + FROM company_profiles + WHERE id = $1 + "#, + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + match company { + Some(c) => Ok(Json(c)), + None => Err((StatusCode::NOT_FOUND, "Company not found".to_string())), + } +} + +async fn approve_company( + _auth: AuthUser, + State(state): State, + Path(id): Path, + Json(_payload): Json, +) -> Result { + let company = sqlx::query!( + "SELECT id, user_id, status FROM company_profiles WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let company = match company { + Some(c) => c, + None => return Err((StatusCode::NOT_FOUND, "Company not found".to_string())), + }; + + if company.status == "APPROVED" { + return Err((StatusCode::BAD_REQUEST, "Company is already approved".to_string())); + } + + sqlx::query!( + "UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1", + id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(Json(serde_json::json!({ + "id": id, + "status": "APPROVED", + "message": "Company approved successfully" + }))) +} + +async fn reject_company( + _auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + let reason = payload.reason.as_deref().unwrap_or("No reason provided"); + + let company = sqlx::query!( + "SELECT id, user_id, status FROM company_profiles WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let company = match company { + Some(c) => c, + None => return Err((StatusCode::NOT_FOUND, "Company not found".to_string())), + }; + + if company.status == "REJECTED" { + return Err((StatusCode::BAD_REQUEST, "Company is already rejected".to_string())); + } + + sqlx::query!( + "UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", + id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(Json(serde_json::json!({ + "id": id, + "status": "REJECTED", + "reason": reason, + "message": "Company rejected" + }))) +} + +async fn suspend_company( + _auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + let reason = payload.reason.as_deref().unwrap_or("No reason provided"); + + let company = sqlx::query!( + "SELECT id, user_id, status FROM company_profiles WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let company = match company { + Some(c) => c, + None => return Err((StatusCode::NOT_FOUND, "Company not found".to_string())), + }; + + if company.status == "SUSPENDED" { + return Err((StatusCode::BAD_REQUEST, "Company is already suspended".to_string())); + } + + sqlx::query!( + "UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1", + id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(Json(serde_json::json!({ + "id": id, + "status": "SUSPENDED", + "reason": reason, + "message": "Company suspended" + }))) +} diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 1da42c7..7267c35 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -18,6 +18,7 @@ use crate::AppState; pub fn router() -> Router { Router::new() .route("/profile/me", get(get_profile).patch(update_profile)) + .route("/profile/submit", post(submit_for_verification)) .route("/jobs", get(list_jobs).post(create_job)) .route("/jobs/{id}", get(get_job).patch(update_job)) .route("/jobs/{id}/submit", post(submit_job)) @@ -74,6 +75,29 @@ async fn update_profile( } } +async fn submit_for_verification( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(Some(c)) => c, + Ok(None) => return (StatusCode::NOT_FOUND, "Company profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if company.status == "PENDING_REVIEW" || company.status == "APPROVED" { + return (StatusCode::BAD_REQUEST, format!("Profile is already {}", company.status)).into_response(); + } + + match CompanyRepository::submit_for_verification(&state.pool, auth.user_id).await { + Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ + "status": profile.status, + "message": "Profile submitted for verification" + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + async fn list_jobs( State(state): State, auth: AuthUser, diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 1a503c7..cd07cd8 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -18,6 +18,7 @@ use crate::AppState; pub fn router() -> Router { Router::new() .route("/profile/me", get(get_profile).patch(update_profile)) + .route("/profile/submit", post(submit_for_verification)) .route("/requirements", get(list_requirements).post(create_requirement)) .route("/requirements/{id}", get(get_requirement).patch(update_requirement)) .route("/requirements/{id}/submit", post(submit_requirement)) @@ -70,6 +71,29 @@ async fn update_profile( } } +async fn submit_for_verification( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(Some(c)) => c, + Ok(None) => return (StatusCode::NOT_FOUND, "Customer profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if customer.status == "PENDING_REVIEW" || customer.status == "APPROVED" { + return (StatusCode::BAD_REQUEST, format!("Profile is already {}", customer.status)).into_response(); + } + + match CustomerRepository::submit_for_verification(&state.pool, auth.user_id).await { + Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ + "status": profile.status, + "message": "Profile submitted for verification" + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + async fn list_requirements( State(state): State, auth: AuthUser, diff --git a/apps/employees/src/handlers/departments.rs b/apps/employees/src/handlers/departments.rs index 8a89bb1..ee8d98a 100644 --- a/apps/employees/src/handlers/departments.rs +++ b/apps/employees/src/handlers/departments.rs @@ -21,6 +21,9 @@ async fn list_departments( auth: AuthUser, State(state): State, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let departments = DepartmentRepository::list(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -33,6 +36,9 @@ async fn create_department( State(state): State, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let department = DepartmentRepository::create(&state.pool, payload) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -46,6 +52,9 @@ async fn update_department( State(state): State, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let department = DepartmentRepository::update(&state.pool, id, payload) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -58,6 +67,9 @@ async fn delete_department( Path(id): Path, State(state): State, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } DepartmentRepository::delete(&state.pool, id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; diff --git a/apps/employees/src/handlers/designations.rs b/apps/employees/src/handlers/designations.rs index eb69729..fa82411 100644 --- a/apps/employees/src/handlers/designations.rs +++ b/apps/employees/src/handlers/designations.rs @@ -6,7 +6,7 @@ use axum::{ routing::{get, post, patch}, Json, Router, }; -use contracts::auth_middleware::AuthUser; +use contracts::auth_middleware::{AuthUser, require_admin}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use db::models::designation::{DesignationRepository, CreateDesignationPayload}; @@ -22,6 +22,9 @@ async fn list_all_designations( auth: AuthUser, State(state): State, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let designations = DesignationRepository::list_all(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -34,6 +37,9 @@ async fn list_by_department( State(state): State, Path(dept_id): Path, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let designations = DesignationRepository::list_by_department(&state.pool, dept_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -46,6 +52,9 @@ async fn create_designation( State(state): State, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let designation = DesignationRepository::create(&state.pool, payload) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -59,6 +68,9 @@ async fn update_designation( State(state): State, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let designation = DesignationRepository::update(&state.pool, id, payload) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -71,6 +83,9 @@ async fn delete_designation( Path(id): Path, State(state): State, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } DesignationRepository::delete(&state.pool, id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; diff --git a/apps/employees/src/handlers/employees.rs b/apps/employees/src/handlers/employees.rs index ba01d72..466bc88 100644 --- a/apps/employees/src/handlers/employees.rs +++ b/apps/employees/src/handlers/employees.rs @@ -34,10 +34,13 @@ pub struct EmployeeResponse { } async fn list_employees( - _auth: AuthUser, + auth: AuthUser, State(state): State, Query(q): Query, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let employees = EmployeeRepository::list(&state.pool, q.q) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -46,10 +49,13 @@ async fn list_employees( } async fn get_employee( - _auth: AuthUser, + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let employee = EmployeeRepository::list(&state.pool, None) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? @@ -61,10 +67,13 @@ async fn get_employee( } async fn create_employee( - _auth: AuthUser, + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let employee = EmployeeRepository::create(&state.pool, payload) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -84,11 +93,14 @@ pub struct UpdateEmployeePayload { } async fn update_employee( - _auth: AuthUser, + auth: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } let employee = EmployeeRepository::update( &state.pool, id, @@ -107,10 +119,13 @@ async fn update_employee( } async fn delete_employee( - _auth: AuthUser, + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + if let Err(_) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string())); + } EmployeeRepository::delete(&state.pool, id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index a4b91e9..1600a63 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -68,7 +68,7 @@ impl Services { payments_url: std::env::var("PAYMENTS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:8094".to_string()), employees_url: std::env::var("EMPLOYEES_SERVICE_URL") - .unwrap_or_else(|_| "http://localhost:8085".to_string()), + .unwrap_or_else(|_| "http://localhost:8096".to_string()), client: reqwest::Client::new(), } } diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index ff3c356..021378d 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -18,6 +18,7 @@ pub fn router() -> Router { Router::new() .route("/profile/me", get(get_profile).patch(update_profile)) .route("/profile/resume", post(upload_resume)) + .route("/profile/submit", post(submit_for_verification)) .route("/jobs", get(browse_jobs)) .route("/jobs/{id}", get(get_job)) .route("/jobs/{id}/apply", post(apply_to_job)) @@ -318,3 +319,26 @@ async fn withdraw_application( Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } + +async fn submit_for_verification( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(Some(s)) => s, + Ok(None) => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if seeker.status == "PENDING_REVIEW" || seeker.status == "APPROVED" { + return (StatusCode::BAD_REQUEST, format!("Profile is already {}", seeker.status)).into_response(); + } + + match JobSeekerRepository::submit_for_verification(&state.pool, auth.user_id).await { + Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ + "status": profile.status, + "message": "Profile submitted for verification" + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/payments/Cargo.toml b/apps/payments/Cargo.toml new file mode 100644 index 0000000..0c6c9a2 --- /dev/null +++ b/apps/payments/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "payments" +version = "0.1.0" +edition.workspace = true + +[dependencies] +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +reqwest = { version = "0.12", features = ["json", "stream"] } +anyhow.workspace = true diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs new file mode 100644 index 0000000..b49eeb6 --- /dev/null +++ b/apps/payments/src/main.rs @@ -0,0 +1,230 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Clone)] +struct AppState { + beeceptor_url: String, + client: reqwest::Client, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CreateOrderRequest { + amount: u64, + currency: Option, + package_id: Option, + user_id: Option, +} + +#[derive(Debug, Serialize)] +struct CreateOrderResponse { + order_id: String, + amount: u64, + currency: String, + status: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct VerifyPaymentRequest { + order_id: String, + payment_id: String, + signature: Option, +} + +#[derive(Debug, Serialize)] +struct VerifyPaymentResponse { + verified: bool, + payment_id: String, + status: String, + message: String, +} + +#[derive(Debug, Serialize)] +struct PaymentStatusResponse { + payment_id: String, + status: String, + amount: u64, + currency: String, +} + +async fn create_order( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + tracing::info!("Creating payment order: amount={}", payload.amount); + + let resp = state + .client + .post(&state.beeceptor_url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "amount": payload.amount, + "currency": payload.currency.as_deref().unwrap_or("INR"), + "package_id": payload.package_id, + "user_id": payload.user_id, + })) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor error: {}", e)))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + if !status.is_success() { + return Err(( + StatusCode::BAD_REQUEST, + body.get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Order creation failed") + .to_string(), + )); + } + + let order_id = body + .get("order_id") + .and_then(|v| v.as_str()) + .unwrap_or("mock_order_123") + .to_string(); + + Ok(Json(CreateOrderResponse { + order_id, + amount: payload.amount, + currency: payload.currency.unwrap_or("INR".to_string()), + status: "created".to_string(), + })) +} + +async fn verify_payment( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + tracing::info!("Verifying payment: order_id={}", payload.order_id); + + let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/')); + + let resp = state + .client + .post(&verify_url) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor error: {}", e)))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + if !status.is_success() { + return Err(( + StatusCode::BAD_REQUEST, + body.get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Verification failed") + .to_string(), + )); + } + + Ok(Json(VerifyPaymentResponse { + verified: true, + payment_id: payload.payment_id, + status: "success".to_string(), + message: "Payment verified successfully".to_string(), + })) +} + +async fn get_payment_status( + State(state): State, + axum::extract::Path(payment_id): axum::extract::Path, +) -> Result, (StatusCode, String)> { + tracing::info!("Getting payment status: payment_id={}", payment_id); + + let status_url = format!("{}/{}", state.beeceptor_url.trim_end_matches('/'), payment_id); + + let resp = state + .client + .get(&status_url) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor error: {}", e)))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + if !status.is_success() { + return Ok(Json(PaymentStatusResponse { + payment_id, + status: "not_found".to_string(), + amount: 0, + currency: "INR".to_string(), + })); + } + + let amount = body.get("amount").and_then(|v| v.as_u64()).unwrap_or(0); + let currency = body + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("INR") + .to_string(); + let status_str = body + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + Ok(Json(PaymentStatusResponse { + payment_id, + status: status_str, + amount, + currency, + })) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let beeceptor_url = std::env::var("BEECEPTOR_URL") + .unwrap_or_else(|_| "https://nxtgauge.free.beeceptor.com".to_string()); + + let state = AppState { + beeceptor_url, + client: reqwest::Client::new(), + }; + + let app = Router::new() + .route("/api/payments/create-order", post(create_order)) + .route("/api/payments/verify", post(verify_payment)) + .route("/api/payments/:id/status", get(get_payment_status)) + .with_state(state); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8094".to_string()) + .parse() + .expect("PORT must be a valid u16"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Payments service (mock via Beeceptor) listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 7423921..3e4c542 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -35,6 +35,7 @@ pub struct LeadRequestPayload { /// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`. pub fn shared_routes(profession_key: &'static str) -> Router { Router::new() + .route("/profile/submit", post(submit_for_verification)) // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", @@ -774,3 +775,25 @@ async fn wallet_invoice_detail( Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } + +async fn submit_for_verification( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + }; + + if prof.status == "PENDING_REVIEW" || prof.status == "APPROVED" { + return (StatusCode::BAD_REQUEST, format!("Profile is already {}", prof.status)).into_response(); + } + + match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id).await { + Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ + "status": profile.status, + "message": "Profile submitted for verification" + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/crates/db/src/models/company.rs b/crates/db/src/models/company.rs index a160e55..883177a 100644 --- a/crates/db/src/models/company.rs +++ b/crates/db/src/models/company.rs @@ -140,4 +140,30 @@ impl CompanyRepository { Ok(profile) } + + pub async fn submit_for_verification( + pool: &PgPool, + user_id: Uuid, + ) -> Result { + let profile = sqlx::query_as!( + CompanyProfile, + r#" + UPDATE company_profiles + SET status = 'PENDING_REVIEW', updated_at = NOW() + WHERE user_id = $1 + RETURNING + id, 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, country, postal_code, status, + free_job_slots, purchased_job_slots, free_contact_views, purchased_contact_views, + created_at, updated_at + "#, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } } diff --git a/crates/db/src/models/customer.rs b/crates/db/src/models/customer.rs index 2c8ef4c..6012f1a 100644 --- a/crates/db/src/models/customer.rs +++ b/crates/db/src/models/customer.rs @@ -116,4 +116,27 @@ impl CustomerRepository { .await?; Ok(()) } + + pub async fn submit_for_verification( + pool: &PgPool, + user_id: Uuid, + ) -> Result { + let profile = sqlx::query_as!( + CustomerProfile, + r#" + UPDATE customer_profiles + SET status = 'PENDING_REVIEW', updated_at = NOW() + WHERE user_id = $1 + RETURNING + id, user_id, full_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, experience_years, custom_data, + created_at, updated_at + "#, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } } diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index 715fd7c..33b1050 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -116,5 +116,28 @@ impl JobSeekerRepository { .await?; Ok(()) } + + pub async fn submit_for_verification( + pool: &PgPool, + user_id: Uuid, + ) -> Result { + let profile = sqlx::query_as!( + JobSeekerProfile, + r#" + UPDATE job_seeker_profiles + SET status = 'PENDING_REVIEW', updated_at = NOW() + WHERE user_id = $1 + RETURNING + id, user_id, full_name, location, summary, experience_years, + skills, resume_url, active_application_count, status, bio, custom_data, + created_at, updated_at + "#, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } } diff --git a/crates/db/src/models/professional.rs b/crates/db/src/models/professional.rs index c594720..51baf49 100644 --- a/crates/db/src/models/professional.rs +++ b/crates/db/src/models/professional.rs @@ -561,4 +561,36 @@ impl ProfessionalRepository { tx.commit().await?; Ok(true) } + + pub async fn submit_for_verification( + pool: &PgPool, + user_id: Uuid, + ) -> Result { + let prof = sqlx::query_as!( + Professional, + "SELECT * FROM professionals WHERE user_id = $1", + user_id + ) + .fetch_one(pool) + .await?; + + if prof.status == "PENDING_REVIEW" || prof.status == "APPROVED" { + return Err(sqlx::Error::Protocol(format!("Professional profile is already {}", prof.status).into())); + } + + let prof = sqlx::query_as!( + Professional, + r#" + UPDATE professionals + SET status = 'PENDING_REVIEW', updated_at = NOW() + WHERE user_id = $1 + RETURNING * + "#, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(prof) + } }