feat: complete rust microservices migration with real db logic

This commit is contained in:
Ashwin Kumar 2026-03-17 20:42:51 +01:00
parent 20684655d7
commit 5640cd4ee5
122 changed files with 7620 additions and 2 deletions

61
.env.example Normal file
View file

@ -0,0 +1,61 @@
# Nxtgauge Backend — Environment Variables
# Copy this file to .env and fill in the values.
# ── Database ─────────────────────────────────────────────────────────────────
POSTGRES_PASSWORD=nxtgauge_dev
DATABASE_URL=postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db
# ── Auth ──────────────────────────────────────────────────────────────────────
# Generate with: openssl rand -base64 64
JWT_SECRET=change-me-to-a-secure-random-string-of-at-least-64-chars
JWT_EXPIRY_MINUTES=15
REFRESH_TOKEN_EXPIRY_DAYS=30
# ── SMTP (ZeptoMail/Zoho) ──────────────────────────────────────────────────
SMTP_HOST=smtp.zeptomail.in
SMTP_PORT=587
SMTP_USER=emailapikey
SMTP_PASS=PHtE6r1ZR+zi3jV88RNW4/O4F8CkPdksqO9iJAhA4YcTD6dQFk1S+dl/wDC3/h97AKYWFfSczo1rt72etOuDLTnrMjlEDWqyqK3sx/VYSPOZsbq6x00esVgYdEfYVYDpcNFj3SPQut7dNA==
SMTP_FROM_EMAIL=support@nxtgauge.com
SMTP_FROM_NAME=NXTGAUGE
# ── Payments ──────────────────────────────────────────────────────────────────
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
# ── Frontend ──────────────────────────────────────────────────────────────────
FRONTEND_URL=http://localhost:3000
# ── Service Ports (local development, for running services individually) ──────
GATEWAY_PORT=8000
USERS_PORT=8080
COMPANIES_PORT=8081
JOB_SEEKERS_PORT=8082
CUSTOMERS_PORT=8083
PHOTOGRAPHERS_PORT=8085
MAKEUP_ARTISTS_PORT=8086
TUTORS_PORT=8087
DEVELOPERS_PORT=8088
VIDEO_EDITORS_PORT=8089
GRAPHIC_DESIGNERS_PORT=8090
SOCIAL_MEDIA_MANAGERS_PORT=8091
FITNESS_TRAINERS_PORT=8092
CATERING_SERVICES_PORT=8093
PAYMENTS_PORT=8094
# ── Service URLs (used by gateway — override only for non-Docker dev) ─────────
USERS_SERVICE_URL=http://localhost:8080
COMPANIES_SERVICE_URL=http://localhost:8081
JOB_SEEKERS_SERVICE_URL=http://localhost:8082
CUSTOMERS_SERVICE_URL=http://localhost:8083
PHOTOGRAPHERS_SERVICE_URL=http://localhost:8085
MAKEUP_ARTISTS_SERVICE_URL=http://localhost:8086
TUTORS_SERVICE_URL=http://localhost:8087
DEVELOPERS_SERVICE_URL=http://localhost:8088
VIDEO_EDITORS_SERVICE_URL=http://localhost:8089
GRAPHIC_DESIGNERS_SERVICE_URL=http://localhost:8090
SOCIAL_MEDIA_MANAGERS_SERVICE_URL=http://localhost:8091
FITNESS_TRAINERS_SERVICE_URL=http://localhost:8092
CATERING_SERVICES_SERVICE_URL=http://localhost:8093
PAYMENTS_SERVICE_URL=http://localhost:8094

View file

@ -3,10 +3,18 @@ resolver = "2"
members = [
"apps/gateway",
"apps/users",
"apps/photographers",
"apps/tutors",
"apps/companies",
"apps/job_seekers",
"apps/customers",
"apps/professionals",
"apps/jobseekers",
"apps/makeup_artists",
"apps/developers",
"apps/video_editors",
"apps/graphic_designers",
"apps/social_media_managers",
"apps/fitness_trainers",
"apps/catering_services",
"crates/contracts",
"crates/config",
"crates/errors",
@ -34,3 +42,5 @@ prost = "0.13"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] }
uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
lettre = { version = "0.11", features = ["tokio-rustls-tls", "serde"] }

View file

@ -0,0 +1,18 @@
[package]
name = "catering_services"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,38 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use sqlx::PgPool;
use db::models::catering_service::{CateringServiceRepository, UpsertCateringServiceProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("CATERING_SERVICE"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match CateringServiceRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertCateringServiceProfilePayload>,
) -> impl IntoResponse {
match CateringServiceRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Catering Services OK" }))
.nest("/api/catering-services", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8093".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Catering Services listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

18
apps/companies/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "companies"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,304 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload};
use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload};
use db::models::application::ApplicationRepository;
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/jobs", get(list_jobs).post(create_job))
.route("/jobs/:id", get(get_job).patch(update_job))
.route("/jobs/:id/submit", post(submit_job))
.route("/jobs/:id/close", post(close_job))
.route("/jobs/:id/applications", get(list_applications))
.route("/applications/:id/status", patch(update_application_status))
.route("/applications/:id/contact", get(view_contact))
}
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub status: Option<String>,
}
#[derive(Deserialize)]
pub struct CreateJobRequest {
pub title: String,
pub description: String,
pub location: String,
pub job_type: Option<String>,
pub salary_min: Option<i32>,
pub salary_max: Option<i32>,
pub experience_years: Option<i32>,
pub skills: Option<Vec<String>>,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateApplicationStatusPayload {
pub status: String,
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertCompanyProfilePayload>,
) -> impl IntoResponse {
match CompanyRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_jobs(
State(pool): State<PgPool>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match JobRepository::list_by_company_id(&pool, company.id, q.status, page, limit).await {
Ok(jobs) => (StatusCode::OK, Json(serde_json::json!({
"data": jobs,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_job(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<CreateJobRequest>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let db_payload = DbCreateJobPayload {
company_id: company.id,
title: payload.title,
category: payload.category,
description: payload.description,
location: payload.location,
job_type: payload.job_type,
salary_min: payload.salary_min,
salary_max: payload.salary_max,
experience_years: payload.experience_years,
skills: payload.skills,
};
match JobRepository::create(&pool, db_payload).await {
Ok(job) => (StatusCode::CREATED, Json(job)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_job(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
_auth: AuthUser,
) -> impl IntoResponse {
match JobRepository::get_by_id(&pool, id).await {
Ok(Some(job)) => (StatusCode::OK, Json(job)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_job(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
Json(payload): Json<DbUpdateJobPayload>,
) -> impl IntoResponse {
// Basic verification: does job belong to auth user's company?
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").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(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
match JobRepository::update(&pool, job.id, payload).await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn submit_job(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").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(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
if job.status != "DRAFT" {
return (StatusCode::BAD_REQUEST, "Job already submitted or live").into_response();
}
match JobRepository::update_status(&pool, job.id, "PENDING_APPROVAL").await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn close_job(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").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(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
match JobRepository::update_status(&pool, job.id, "CLOSED").await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_applications(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").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(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ApplicationRepository::list_by_job_id(&pool, job.id, q.status, page, limit).await {
Ok(apps) => (StatusCode::OK, Json(serde_json::json!({
"data": apps,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_application_status(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
Json(payload): Json<UpdateApplicationStatusPayload>,
) -> impl IntoResponse {
let app = match ApplicationRepository::get_by_id(&pool, id).await {
Ok(Some(a)) => a,
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
let job = match JobRepository::get_by_id(&pool, app.job_id).await {
Ok(Some(j)) => j,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
};
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
};
if job.company_id != company.id {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
match ApplicationRepository::update_status(&pool, app.id, &payload.status).await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn view_contact(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let app = match ApplicationRepository::get_by_id(&pool, id).await {
Ok(Some(a)) => a,
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
let job = match JobRepository::get_by_id(&pool, app.job_id).await {
Ok(Some(j)) => j,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
};
let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
};
if job.company_id != company.id {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
// TODO: logic to deduct quota + fetch job seeker contact info from users table
// For now, just mark viewed and return placeholder
let _ = ApplicationRepository::mark_contact_viewed(&pool, app.id).await;
(StatusCode::OK, Json(serde_json::json!({
"application_id": id.to_string(),
"full_name": "Applicant Contact Info Locked",
"email": "hidden@example.com",
"phone": "+91 0000000000",
"message": "Contact revealed"
}))).into_response()
}

View file

@ -0,0 +1,49 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[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 database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
tracing::info!("Companies service — connected to database");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Companies Service OK" }))
.nest("/api/companies", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8081".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Companies service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

18
apps/customers/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "customers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,259 @@
uuse axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload};
use db::models::lead_request::LeadRequestRepository;
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.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/requests", get(list_requests))
.route("/requirements/:id/requests/:lead_id/approve", post(approve_request))
.route("/requirements/:id/requests/:lead_id/reject", post(reject_request))
}
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct CreateRequirementRequest {
pub profession_key: String,
pub title: String,
pub description: String,
pub location: String,
pub budget: Option<i32>,
pub preferred_date: Option<String>,
pub extra_data_json: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct RejectRequestPayload {
pub reason: Option<String>,
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match CustomerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertCustomerProfilePayload>,
) -> impl IntoResponse {
match CustomerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_requirements(
State(pool): State<PgPool>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> 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(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match RequirementRepository::list_by_customer_id(&pool, customer.id, page, limit).await {
Ok(reqs) => (StatusCode::OK, Json(serde_json::json!({
"data": reqs,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_requirement(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<CreateRequirementRequest>,
) -> 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.active_requirement_count >= 2 {
return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response();
}
let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let db_payload = DbCreateRequirementPayload {
customer_id: customer.id,
profession_key: payload.profession_key,
title: payload.title,
description: payload.description,
location: payload.location,
budget: payload.budget,
preferred_date: p_date,
extra_data_json: payload.extra_data_json,
};
match RequirementRepository::create(&pool, db_payload).await {
Ok(req) => {
let _ = CustomerRepository::update_active_requirement_count(&pool, customer.id, 1).await;
(StatusCode::CREATED, Json(req)).into_response()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_requirement(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
_auth: AuthUser,
) -> impl IntoResponse {
match RequirementRepository::get_by_id(&pool, id).await {
Ok(Some(req)) => (StatusCode::OK, Json(req)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_requirement(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
Json(payload): Json<DbUpdateRequirementPayload>,
) -> 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(),
};
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(),
};
match RequirementRepository::update(&pool, req.id, payload).await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_requests(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> 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(),
};
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(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match LeadRequestRepository::list_by_requirement_id(&pool, req.id, page, limit).await {
Ok(leads) => (StatusCode::OK, Json(serde_json::json!({
"data": leads,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn approve_request(
State(pool): State<PgPool>,
Path((req_id, lead_id)): Path<(Uuid, 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(),
};
let req = match RequirementRepository::get_by_id(&pool, req_id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
let lead = match LeadRequestRepository::get_by_id(&pool, lead_id).await {
Ok(Some(l)) if l.requirement_id == req.id => l,
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
};
if lead.status != "PENDING" {
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
}
match LeadRequestRepository::update_status(&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()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn reject_request(
State(pool): State<PgPool>,
Path((req_id, lead_id)): Path<(Uuid, Uuid)>,
auth: AuthUser,
Json(_payload): Json<RejectRequestPayload>,
) -> 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(),
};
let req = match RequirementRepository::get_by_id(&pool, req_id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
let lead = match LeadRequestRepository::get_by_id(&pool, lead_id).await {
Ok(Some(l)) if l.requirement_id == req.id => l,
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
};
if lead.status != "PENDING" {
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
}
match LeadRequestRepository::update_status(&pool, lead.id, "REJECTED").await {
Ok(updated) => {
// TODO: Return reserved Tracecoins to professional
(StatusCode::OK, Json(updated)).into_response()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,47 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[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 database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Customers Service OK" }))
.nest("/api/customers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8083".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Customers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "developers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,39 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use db::models::developer::{DeveloperRepository, UpsertDeveloperProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("DEVELOPER"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match DeveloperRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertDeveloperProfilePayload>,
) -> impl IntoResponse {
match DeveloperRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Developers Service OK" }))
.nest("/api/developers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8088".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Developers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "fitness_trainers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,38 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use db::models::fitness_trainer::{FitnessTrainerRepository, UpsertFitnessTrainerProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("FITNESS_TRAINER"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match FitnessTrainerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertFitnessTrainerProfilePayload>,
) -> impl IntoResponse {
match FitnessTrainerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Fitness Trainers Service OK" }))
.nest("/api/fitness-trainers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8092".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Fitness Trainers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

15
apps/gateway/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "gateway"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tower-http = { version = "0.6", features = ["proxy", "cors"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { version = "0.12", features = ["json"] }
anyhow = { workspace = true }

230
apps/gateway/src/main.rs Normal file
View file

@ -0,0 +1,230 @@
use axum::{
body::Body,
extract::{Request, State},
http::{StatusCode, Uri},
response::IntoResponse,
routing::any,
Router,
};
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
struct Services {
users_url: String,
companies_url: String,
job_seekers_url: String,
customers_url: String,
// ── 9 separate profession services ────────────────────────────────────
photographers_url: String,
makeup_artists_url: String,
tutors_url: String,
developers_url: String,
video_editors_url: String,
graphic_designers_url: String,
social_media_managers_url: String,
fitness_trainers_url: String,
catering_services_url: String,
// ── Payments ─────────────────────────────────────────────────────────
payments_url: String,
client: reqwest::Client,
}
impl Services {
fn from_env() -> Self {
Self {
users_url: std::env::var("USERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string()),
companies_url: std::env::var("COMPANIES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8081".to_string()),
job_seekers_url: std::env::var("JOB_SEEKERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8082".to_string()),
customers_url: std::env::var("CUSTOMERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8083".to_string()),
photographers_url: std::env::var("PHOTOGRAPHERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8085".to_string()),
makeup_artists_url: std::env::var("MAKEUP_ARTISTS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8086".to_string()),
tutors_url: std::env::var("TUTORS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8087".to_string()),
developers_url: std::env::var("DEVELOPERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8088".to_string()),
video_editors_url: std::env::var("VIDEO_EDITORS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8089".to_string()),
graphic_designers_url: std::env::var("GRAPHIC_DESIGNERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8090".to_string()),
social_media_managers_url: std::env::var("SOCIAL_MEDIA_MANAGERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8091".to_string()),
fitness_trainers_url: std::env::var("FITNESS_TRAINERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8092".to_string()),
catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8093".to_string()),
payments_url: std::env::var("PAYMENTS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8094".to_string()),
client: reqwest::Client::new(),
}
}
fn resolve_upstream(&self, path: &str) -> Option<String> {
// Auth, users, roles, notifications, runtime-config, config, admin
if path.starts_with("/api/auth")
|| path.starts_with("/api/me")
|| path.starts_with("/api/runtime-config")
|| path.starts_with("/api/config")
|| path.starts_with("/api/admin/roles")
|| path.starts_with("/api/admin/onboarding-config")
|| path.starts_with("/api/admin/dashboard-config")
|| path.starts_with("/api/admin/users")
|| path.starts_with("/api/admin/employees")
|| path.starts_with("/api/admin/departments")
|| path.starts_with("/api/admin/designations")
|| path.starts_with("/api/admin/approvals")
|| path.starts_with("/api/onboarding")
{
Some(self.users_url.clone())
}
// Companies + Jobs + Applications + Packages
else if path.starts_with("/api/companies")
|| path.starts_with("/api/jobs")
|| path.starts_with("/api/applications")
|| path.starts_with("/api/pricing")
|| path.starts_with("/api/admin/companies")
|| path.starts_with("/api/admin/jobs")
{
Some(self.companies_url.clone())
}
// Job Seekers
else if path.starts_with("/api/jobseeker") {
Some(self.job_seekers_url.clone())
}
// Customers + Requirements
else if path.starts_with("/api/customers")
|| path.starts_with("/api/admin/customers")
|| path.starts_with("/api/admin/requirements")
{
Some(self.customers_url.clone())
}
// ── 9 Separate Profession Services ────────────────────────────────
else if path.starts_with("/api/photographers") {
Some(self.photographers_url.clone())
}
else if path.starts_with("/api/makeup-artists") {
Some(self.makeup_artists_url.clone())
}
else if path.starts_with("/api/tutors") {
Some(self.tutors_url.clone())
}
else if path.starts_with("/api/developers") {
Some(self.developers_url.clone())
}
else if path.starts_with("/api/video-editors") {
Some(self.video_editors_url.clone())
}
else if path.starts_with("/api/graphic-designers") {
Some(self.graphic_designers_url.clone())
}
else if path.starts_with("/api/social-media-managers") {
Some(self.social_media_managers_url.clone())
}
else if path.starts_with("/api/fitness-trainers") {
Some(self.fitness_trainers_url.clone())
}
else if path.starts_with("/api/catering-services") {
Some(self.catering_services_url.clone())
}
// ── Payments + Invoices ───────────────────────────────────────────
else if path.starts_with("/api/payments")
|| path.starts_with("/api/admin/invoices")
|| path.starts_with("/api/admin/credits")
|| path.starts_with("/api/admin/revenue")
|| path.starts_with("/api/admin/pricing")
{
Some(self.payments_url.clone())
}
// Wallet / Tracecoins (shared across profession services — route to a credits service)
else if path.starts_with("/api/credits") {
Some(self.payments_url.clone())
}
else {
None
}
}
}
#[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 services = Services::from_env();
let app = Router::new()
.route("/api/*path", any(proxy_handler))
.route("/health", any(|| async { "Gateway OK" }))
.layer(CorsLayer::permissive())
.with_state(services);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8000".to_string())
.parse()
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Gateway listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn proxy_handler(
State(services): State<Services>,
req: Request,
) -> impl IntoResponse {
let path = req.uri().path().to_string();
let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default();
let Some(upstream_base) = services.resolve_upstream(&path) else {
return (StatusCode::NOT_FOUND, "Route not found in gateway").into_response();
};
let target_url = format!("{}{}{}", upstream_base, path, query);
let method = req.method().clone();
let headers = req.headers().clone();
let body = req.into_body();
match services
.client
.request(method, &target_url)
.headers(headers)
.body(reqwest::Body::wrap_stream(body.into_data_stream()))
.send()
.await
{
Ok(res) => {
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let res_headers = res.headers().clone();
let bytes = res.bytes().await.unwrap_or_default();
let res_body = Body::from(bytes);
let mut response = res_body.into_response();
*response.status_mut() = status;
for (name, value) in res_headers {
if let Some(name) = name {
response.headers_mut().insert(name, value);
}
}
response
}
Err(e) => {
tracing::error!("Gateway proxy error → {}: {}", target_url, e);
(StatusCode::BAD_GATEWAY, format!("Gateway error: {}", e)).into_response()
}
}
}

View file

@ -0,0 +1,18 @@
[package]
name = "graphic_designers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,38 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use sqlx::PgPool;
use db::models::graphic_designer::{GraphicDesignerRepository, UpsertGraphicDesignerProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("GRAPHIC_DESIGNER"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match GraphicDesignerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertGraphicDesignerProfilePayload>,
) -> impl IntoResponse {
match GraphicDesignerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Graphic Designers Service OK" }))
.nest("/api/graphic-designers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8090".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Graphic Designers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "job_seekers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,238 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use db::models::job_seeker::{JobSeekerRepository, UpsertJobSeekerProfilePayload};
use db::models::job::JobRepository;
use db::models::application::{ApplicationRepository, CreateApplicationPayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/profile/resume", post(upload_resume))
.route("/jobs", get(browse_jobs))
.route("/jobs/:id", get(get_job))
.route("/jobs/:id/apply", post(apply_to_job))
.route("/applications", get(list_my_applications))
.route("/applications/:id", get(get_my_application))
.route("/applications/:id/withdraw", post(withdraw_application))
}
#[derive(Deserialize)]
pub struct JobBrowseQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub location: Option<String>,
pub job_type: Option<String>,
pub search: Option<String>,
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub cover_letter: Option<String>,
pub resume_url: Option<String>,
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertJobSeekerProfilePayload>,
) -> impl IntoResponse {
match JobSeekerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn upload_resume(
State(_pool): State<PgPool>,
_auth: AuthUser,
) -> impl IntoResponse {
// TODO: multipart upload handler
(StatusCode::OK, Json(serde_json::json!({ "resume_url": null })))
}
async fn browse_jobs(
State(pool): State<PgPool>,
Query(q): Query<JobBrowseQuery>,
) -> impl IntoResponse {
// Public feed of LIVE jobs
// Note: This logic should ideally be in JobRepository but for now it's simple listing
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
// Filter by LIVE status for public browse
let jobs = sqlx::query_as!(
db::models::job::Job,
r#"
SELECT * FROM jobs
WHERE status = 'LIVE'
AND ($1::VARCHAR IS NULL OR location ILIKE '%' || $1 || '%')
AND ($2::VARCHAR IS NULL OR job_type = $2)
AND ($3::VARCHAR IS NULL OR title ILIKE '%' || $3 || '%')
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
"#,
q.location,
q.job_type,
q.search,
limit,
offset
)
.fetch_all(&pool)
.await;
match jobs {
Ok(j) => (StatusCode::OK, Json(serde_json::json!({
"data": j,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_job(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match JobRepository::get_by_id(&pool, id).await {
Ok(Some(job)) if job.status == "LIVE" => (StatusCode::OK, Json(job)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Job is not live").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn apply_to_job(
State(pool): State<PgPool>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(payload): Json<ApplyRequest>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
let job = match JobRepository::get_by_id(&pool, id).await {
Ok(Some(j)) if j.status == "LIVE" => j,
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Job is not live").into_response(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
if seeker.active_application_count >= 50 {
return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response();
}
let db_payload = CreateApplicationPayload {
job_id: job.id,
job_seeker_id: seeker.id,
cover_letter: payload.cover_letter,
resume_url: payload.resume_url.or(seeker.resume_url),
};
match ApplicationRepository::create(&pool, db_payload).await {
Ok(app) => {
let _ = JobSeekerRepository::update_active_application_count(&pool, seeker.id, 1).await;
(StatusCode::CREATED, Json(app)).into_response()
},
Err(e) => {
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Already applied to this job").into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
}
async fn list_my_applications(
State(pool): State<PgPool>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ApplicationRepository::list_by_job_seeker_id(&pool, seeker.id, page, limit).await {
Ok(apps) => (StatusCode::OK, Json(serde_json::json!({
"data": apps,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_my_application(
State(pool): State<PgPool>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
match ApplicationRepository::get_by_id(&pool, id).await {
Ok(Some(app)) if app.job_seeker_id == seeker.id => (StatusCode::OK, Json(app)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Access denied").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Application not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn withdraw_application(
State(pool): State<PgPool>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
let app = match ApplicationRepository::get_by_id(&pool, id).await {
Ok(Some(a)) if a.job_seeker_id == seeker.id => a,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
match ApplicationRepository::update_status(&pool, app.id, "WITHDRAWN").await {
Ok(updated) => {
let _ = JobSeekerRepository::update_active_application_count(&pool, seeker.id, -1).await;
(StatusCode::OK, Json(updated)).into_response()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
}

View file

@ -0,0 +1,47 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[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 database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Job Seekers Service OK" }))
.nest("/api/jobseeker", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8082".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Job Seekers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "makeup_artists"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,39 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use db::models::makeup_artist::{MakeupArtistRepository, UpsertMakeupArtistProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("MAKEUP_ARTIST"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match MakeupArtistRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertMakeupArtistProfilePayload>,
) -> impl IntoResponse {
match MakeupArtistRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Makeup Artists Service OK" }))
.nest("/api/makeup-artists", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8086".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Makeup Artists service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "photographers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,41 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use db::models::photographer::{PhotographerRepository, UpsertPhotographerProfilePayload};
use db::models::professional::ProfessionalRepository;
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
// All shared routes (marketplace, leads, portfolio, services, wallet)
.merge(contracts::profession_shared::shared_routes("PHOTOGRAPHER"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match PhotographerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertPhotographerProfilePayload>,
) -> impl IntoResponse {
match PhotographerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,45 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Photographers Service OK" }))
.nest("/api/photographers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8085".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Photographers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,202 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
/// Shared professional router — all 9 profession apps mount this.
/// Each profession app wraps this under its own /api/<profession_key> path.
pub fn router() -> Router<PgPool> {
Router::new()
// Professional profile
.route("/profile/me", get(get_profile).patch(update_profile))
// Marketplace (requirements feed)
.route("/marketplace", get(browse_marketplace))
.route("/marketplace/:id", get(get_requirement_detail))
// Lead requests
.route("/leads/request", post(send_lead_request))
.route("/leads/requests/me", get(my_requests))
.route("/leads/requests/:id", delete(cancel_request))
.route("/leads/accepted/me", get(accepted_leads))
.route("/leads/accepted/:id", get(accepted_lead_detail))
// Portfolio
.route("/portfolio/me", get(list_portfolio))
.route("/portfolio", post(create_portfolio_item))
.route("/portfolio/:id", patch(update_portfolio_item).delete(delete_portfolio_item))
// Services
.route("/services/me", get(list_services))
.route("/services", post(create_service))
.route("/services/:id", patch(update_service).delete(delete_service))
// Wallet
.route("/wallet/balance", get(wallet_balance))
.route("/wallet/ledger", get(wallet_ledger))
.route("/wallet/invoices", get(wallet_invoices))
.route("/wallet/invoices/:id", get(wallet_invoice_detail))
}
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub profession_key: Option<String>,
}
#[derive(Deserialize)]
pub struct LeadRequestPayload {
pub requirement_id: String,
}
#[derive(Deserialize)]
pub struct CreatePortfolioPayload {
pub title: String,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct CreateServicePayload {
pub name: String,
pub description: Option<String>,
pub price: i32, // in paise
pub duration_minutes: Option<i32>,
}
async fn get_profile(State(pool): State<PgPool>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": null, "display_name": null, "status": "ACTIVE" })))
}
async fn update_profile(State(pool): State<PgPool>, Json(_p): Json<serde_json::Value>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "message": "Profile updated" })))
}
async fn browse_marketplace(
State(pool): State<PgPool>,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let _ = pool;
// Returns OPEN + non-expired requirements for this profession_key
(StatusCode::OK, Json(serde_json::json!({
"data": [],
"pagination": { "page": q.page.unwrap_or(1), "limit": q.limit.unwrap_or(20), "total": 0 }
})))
}
async fn get_requirement_detail(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let _ = pool;
// Customer contact details NOT included — only revealed after acceptance
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "status": "OPEN" })))
}
async fn send_lead_request(
State(pool): State<PgPool>,
Json(payload): Json<LeadRequestPayload>,
) -> impl IntoResponse {
let _ = pool;
// Server enforces: 25 Tracecoins reserved, requirement OPEN, max 20 requests, no duplicate
let req_id = payload.requirement_id.clone();
(StatusCode::CREATED, Json(serde_json::json!({
"id": Uuid::new_v4().to_string(),
"requirement_id": req_id,
"status": "PENDING",
"tracecoins_reserved": 25,
"expires_at": (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339()
})))
}
async fn my_requests(State(pool): State<PgPool>, Query(q): Query<PaginationQuery>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } })))
}
async fn cancel_request(State(pool): State<PgPool>, Path(id): Path<Uuid>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Request cancelled" })))
}
async fn accepted_leads(State(pool): State<PgPool>, Query(q): Query<PaginationQuery>) -> impl IntoResponse {
let _ = pool;
// Returns leads where status = ACCEPTED — includes customer contact info
(StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } })))
}
async fn accepted_lead_detail(State(pool): State<PgPool>, Path(id): Path<Uuid>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
}
async fn list_portfolio(State(pool): State<PgPool>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn create_portfolio_item(
State(pool): State<PgPool>,
Json(p): Json<CreatePortfolioPayload>,
) -> impl IntoResponse {
let _ = pool;
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string(), "title": p.title })))
}
async fn update_portfolio_item(State(pool): State<PgPool>, Path(id): Path<Uuid>, Json(_p): Json<serde_json::Value>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Updated" })))
}
async fn delete_portfolio_item(State(pool): State<PgPool>, Path(id): Path<Uuid>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Deleted" })))
}
async fn list_services(State(pool): State<PgPool>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn create_service(
State(pool): State<PgPool>,
Json(p): Json<CreateServicePayload>,
) -> impl IntoResponse {
let _ = pool;
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string(), "name": p.name })))
}
async fn update_service(State(pool): State<PgPool>, Path(id): Path<Uuid>, Json(_p): Json<serde_json::Value>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Updated" })))
}
async fn delete_service(State(pool): State<PgPool>, Path(id): Path<Uuid>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Deleted" })))
}
async fn wallet_balance(State(pool): State<PgPool>) -> impl IntoResponse {
let _ = pool;
// Read-only — no write on client
(StatusCode::OK, Json(serde_json::json!({ "balance": 0, "reserved": 0, "available": 0 })))
}
async fn wallet_ledger(State(pool): State<PgPool>, Query(q): Query<PaginationQuery>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } })))
}
async fn wallet_invoices(State(pool): State<PgPool>, Query(q): Query<PaginationQuery>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } })))
}
async fn wallet_invoice_detail(State(pool): State<PgPool>, Path(id): Path<Uuid>) -> impl IntoResponse {
let _ = pool;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
}

View file

@ -0,0 +1,50 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[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 database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
tracing::info!("Professionals service — connected to database");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Professionals Service OK" }))
// All 9 profession types share the same router under /api/professionals
.nest("/api/professionals", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8084".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Professionals service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "social_media_managers"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,38 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, patch}, // Re-adding patch here as it's used in the router
Json, Router,
};
use sqlx::PgPool;
use db::models::social_media_manager::{SocialMediaManagerRepository, UpsertSocialMediaManagerProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("SOCIAL_MEDIA_MANAGER"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match SocialMediaManagerRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertSocialMediaManagerProfilePayload>,
) -> impl IntoResponse {
match SocialMediaManagerRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Social Media Managers Service OK" }))
.nest("/api/social-media-managers", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8091".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Social Media Managers service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

18
apps/tutors/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "tutors"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,40 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use db::models::tutor::{TutorRepository, UpsertTutorProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("TUTOR"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match TutorRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertTutorProfilePayload>,
) -> impl IntoResponse {
match TutorRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

42
apps/tutors/src/main.rs Normal file
View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Tutors Service OK" }))
.nest("/api/tutors", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8087".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Tutors service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

21
apps/users/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "users"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
lettre = { workspace = true }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,458 @@
use auth::{
crypto::{hash_password, verify_password},
jwt::generate_tokens,
};
use ax_um_state_alias::AppState; // I'll use crate::AppState
use axum::{
extract::State,
http::{header::SET_COOKIE, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use chrono::{Duration, Utc};
use db::models::user::{CreateUserPayload, UserRepository};
use serde::{Deserialize, Serialize};
use contracts::auth_middleware::AuthUser;
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
.route("/refresh", post(refresh))
.route("/session", get(session))
.route("/verify-email", post(verify_email))
.route("/resend-otp", post(resend_otp))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
.route("/change-password", post(change_password))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RegisterPayload {
pub full_name: String,
pub email: String,
pub phone: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct LoginPayload {
pub email: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct VerifyEmailPayload {
pub otp: String,
}
#[derive(Deserialize)]
pub struct ForgotPasswordPayload {
pub email: String,
}
#[derive(Deserialize)]
pub struct ResetPasswordPayload {
pub token: String,
pub new_password: String,
}
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
pub current_password: String,
pub new_password: String,
}
#[derive(Serialize)]
pub struct RegisterResponse {
pub user_id: String,
pub email: String,
pub phone: String,
pub full_name: String,
pub status: String,
pub email_verified: bool,
pub created_at: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub user: SessionUser,
}
#[derive(Serialize)]
pub struct SessionUser {
pub id: String,
pub email: String,
pub full_name: String,
pub email_verified: bool,
pub roles: Vec<String>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
#[serde(rename = "statusCode")]
pub status_code: u16,
}
fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResponse>) {
(
status,
Json(ErrorResponse {
error: msg.to_string(),
code: code.to_string(),
status_code: status.as_u16(),
}),
)
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
// Basic validation
if payload.password.len() < 8 {
return Err(err(
StatusCode::UNPROCESSABLE_ENTITY,
"Password minimum 8 characters",
"VALIDATION_ERROR",
));
}
let password_hash = hash_password(&payload.password).map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
"INTERNAL_ERROR",
)
})?;
let user = UserRepository::create(
&state.pool,
CreateUserPayload {
full_name: payload.full_name,
email: payload.email.to_lowercase(),
phone: payload.phone,
password_hash,
},
)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("users_email_key") || msg.contains("email") && msg.contains("unique") {
err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS")
} else if msg.contains("users_phone_key") || msg.contains("phone") && msg.contains("unique") {
err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS")
} else {
err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR")
}
})?;
// Generate and send email OTP for verification
let otp = format!("{:06}", rand::random::<u32>() % 1000000);
let expires_at = Utc::now() + Duration::minutes(15);
UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
user_id: user.id.to_string(),
email: user.email,
phone: user.phone.unwrap_or_default(),
full_name: user.full_name.unwrap_or_default(),
status: user.status,
email_verified: user.email_verified,
created_at: user.created_at.to_rfc3339(),
}),
))
}
async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase())
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?;
// Check account status
if user.status == "SUSPENDED" {
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
}
// Email verification check
if !user.email_verified {
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified", "EMAIL_NOT_VERIFIED"));
}
let is_valid = verify_password(&payload.password, &user.password_hash).map_err(|e| {
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
})?;
if !is_valid {
return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"));
}
// Fetch user's active roles
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
.await
.unwrap_or_default();
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string());
let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
UserRepository::store_refresh_token(
&state.pool,
user.id,
&tokens.refresh_token,
Utc::now() + Duration::days(30),
)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
// Set refresh token as httpOnly cookie
let cookie = format!(
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
tokens.refresh_token
);
let response = Json(LoginResponse {
access_token: tokens.access_token,
token_type: "Bearer".to_string(),
expires_in: 900,
user: SessionUser {
id: user.id.to_string(),
email: user.email,
full_name: user.full_name.unwrap_or_default(),
email_verified: user.email_verified,
roles: user_roles,
},
});
Ok((
StatusCode::OK,
[(SET_COOKIE, cookie)],
response,
))
}
async fn logout(
State(state): State<AppState>,
// In real implementation: extract refresh token from cookie header
) -> impl IntoResponse {
// TODO: Revoke refresh token from cookie
let _ = &state.pool;
(StatusCode::OK, Json(serde_json::json!({ "message": "Logged out successfully" })))
}
async fn refresh(
State(state): State<AppState>,
// In real impl: read httpOnly cookie, not body
Json(payload): Json<serde_json::Value>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let token = payload["refresh_token"]
.as_str()
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;
let rt = UserRepository::get_valid_refresh_token(&state.pool, token)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?;
let user = UserRepository::get_by_id(&state.pool, rt.user_id)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?;
let _ = UserRepository::revoke_refresh_token(&state.pool, token).await;
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
.await
.unwrap_or_default();
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string());
let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
UserRepository::store_refresh_token(
&state.pool,
user.id,
&tokens.refresh_token,
Utc::now() + Duration::days(30),
)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"access_token": tokens.access_token,
"expires_in": 900
})),
))
}
async fn session(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
.await
.unwrap_or_default();
Ok(Json(SessionUser {
id: user.id.to_string(),
email: user.email,
full_name: user.full_name.unwrap_or_default(),
email_verified: user.email_verified,
roles: user_roles,
}))
}
async fn verify_email(
State(state): State<AppState>,
Json(payload): Json<VerifyEmailPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_verification_token(&state.pool, &payload.otp)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?;
if let Some(expires_at) = user.email_verification_expires_at {
if expires_at < Utc::now() {
return Err(err(StatusCode::UNAUTHORIZED, "Verification code expired", "CODE_EXPIRED"));
}
}
UserRepository::set_email_verified(&state.pool, user.id)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
}
#[derive(Deserialize)]
pub struct ResendOtpPayload {
pub email: String,
}
async fn resend_otp(
State(state): State<AppState>,
Json(payload): Json<ResendOtpPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_email(&state.pool, &payload.email)
.await
.map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" }))))?;
let otp = format!("{:06}", rand::random::<u32>() % 1000000);
let expires_at = Utc::now() + Duration::minutes(15);
UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" }))))
}
async fn forgot_password(
State(state): State<AppState>,
Json(payload): Json<ForgotPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_email(&state.pool, &payload.email)
.await
.map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))))?;
let token: String = uuid::Uuid::new_v4().to_string();
let expires_at = Utc::now() + Duration::hours(1);
UserRepository::set_reset_token(&state.pool, user.id, &token, expires_at)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let _ = state.mail.send_password_reset_email(&user.email, &user.full_name.unwrap_or_default(), &token).await;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))))
}
async fn reset_password(
State(state): State<AppState>,
Json(payload): Json<ResetPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_reset_token(&state.pool, &payload.token)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?;
if let Some(expires_at) = user.reset_password_expires_at {
if expires_at < Utc::now() {
return Err(err(StatusCode::UNAUTHORIZED, "Reset token expired", "TOKEN_EXPIRED"));
}
}
if payload.new_password.len() < 8 {
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR"));
}
let password_hash = hash_password(&payload.new_password).map_err(|e| {
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
})?;
UserRepository::update_password(&state.pool, user.id, &password_hash)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
UserRepository::clear_reset_token(&state.pool, user.id)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
}
async fn change_password(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<ChangePasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;
if !verify_password(&payload.current_password, &user.password_hash).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))? {
return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD"));
}
if payload.new_password.len() < 8 {
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR"));
}
let password_hash = hash_password(&payload.new_password).map_err(|e| {
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
})?;
UserRepository::update_password(&state.pool, user.id, &password_hash)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
}

View file

@ -0,0 +1,215 @@
use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use db::models::config::{
ConfigRepository, CreateDashboardConfigPayload, CreateOnboardingConfigPayload,
CreateRuntimeConfigPayload,
};
use serde::Deserialize;
use uuid::Uuid;
pub fn onboarding_router() -> Router<AppState> {
Router::new()
.route("/", get(list_onboarding_configs).post(create_onboarding_config))
.route("/:role_id", get(get_active_onboarding_config))
.route("/by-key/:role_key", get(get_onboarding_config_by_key))
}
pub fn dashboard_router() -> Router<AppState> {
Router::new()
.route("/", get(list_dashboard_configs).post(create_dashboard_config))
.route("/:role_id", get(get_active_dashboard_config))
.route("/by-key/:role_key", get(get_dashboard_config_by_key))
}
pub fn runtime_router() -> Router<AppState> {
Router::new()
.route("/", get(get_my_runtime_config).post(create_runtime_config))
.route("/:role_id", get(get_active_runtime_config))
}
async fn get_my_runtime_config(
auth: contracts::auth_middleware::AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// 1. Get user's active role from the token
// 2. Fetch runtime config for that role
// If no active role, maybe default to a "guest" or just return error
let role_key = auth.active_role.clone().unwrap_or_else(|| "CUSTOMER".to_string());
match ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
format!("No runtime config found for role {}", role_key),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn create_onboarding_config(
State(state): State<AppState>,
Json(payload): Json<CreateOnboardingConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_onboarding_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_active_onboarding_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_onboarding_config(&state.pool, role_id).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active onboarding config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn list_onboarding_configs(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_all_onboarding_configs(&state.pool).await {
Ok(configs) => Ok((StatusCode::OK, Json(configs))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn create_dashboard_config(
State(state): State<AppState>,
Json(payload): Json<CreateDashboardConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_dashboard_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
#[derive(Deserialize)]
struct DashboardQuery {
audience: String,
}
async fn get_active_dashboard_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_dashboard_config(&state.pool, role_id, &query.audience).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active dashboard config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn list_dashboard_configs(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_all_dashboard_configs(&state.pool).await {
Ok(configs) => Ok((StatusCode::OK, Json(configs))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn create_runtime_config(
State(state): State<AppState>,
Json(payload): Json<CreateRuntimeConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_runtime_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_active_runtime_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_runtime_config(&state.pool, role_id).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active runtime config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_onboarding_config_by_key(
State(state): State<AppState>,
Path(role_key): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_onboarding_by_role_key(&state.pool, &role_key).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
format!("Active onboarding config for role '{}' not found", role_key),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_dashboard_config_by_key(
State(state): State<AppState>,
Path(role_key): Path<String>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_dashboard_by_role_key(&state.pool, &role_key, &query.audience).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
format!("Active dashboard config for role '{}' not found", role_key),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}

View file

@ -0,0 +1,3 @@
pub mod config;
pub mod roles;
pub mod auth;

View file

@ -0,0 +1,85 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use serde::Serialize;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_notifications))
.route("/unread-count", get(unread_count))
.route("/:id/read", patch(mark_read))
.route("/read-all", patch(mark_all_read))
}
#[derive(Serialize)]
pub struct NotificationDto {
pub id: String,
pub title: String,
pub body: Option<String>,
#[serde(rename = "type")]
pub notification_type: Option<String>,
pub is_read: bool,
pub created_at: String,
}
#[derive(Serialize)]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub pagination: Pagination,
}
#[derive(Serialize)]
pub struct Pagination {
pub page: i64,
pub limit: i64,
pub total: i64,
pub total_pages: i64,
}
// TODO: Replace with real JWT extractor middleware
// For now this handler is a placeholder that shows the expected shape.
async fn list_notifications(
State(state): State<AppState>,
// TODO: axum::extract::Query for page/limit
// TODO: JWT middleware to get user_id
) -> impl IntoResponse {
let _ = state;
(
StatusCode::OK,
Json(PaginatedResponse::<NotificationDto> {
data: vec![],
pagination: Pagination {
page: 1,
limit: 20,
total: 0,
total_pages: 0,
},
}),
)
}
async fn unread_count(State(state): State<AppState>) -> impl IntoResponse {
let _ = state;
(StatusCode::OK, Json(serde_json::json!({ "unread_count": 0 })))
}
async fn mark_read(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let _ = state;
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "is_read": true })))
}
async fn mark_all_read(State(state): State<AppState>) -> impl IntoResponse {
let _ = state;
(StatusCode::OK, Json(serde_json::json!({ "message": "All notifications marked as read" })))
}

View file

@ -0,0 +1,55 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use db::models::role::{CreateRolePayload, RoleRepository};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_roles).post(create_role))
.route("/:key", get(get_role_by_key))
}
async fn create_role(
State(state): State<AppState>,
Json(payload): Json<CreateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match RoleRepository::create(&state.pool, payload).await {
Ok(role) => Ok((StatusCode::CREATED, Json(role))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn list_roles(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match RoleRepository::get_all(&state.pool).await {
Ok(roles) => Ok((StatusCode::OK, Json(roles))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_role_by_key(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match RoleRepository::get_by_key(&state.pool, &key).await {
Ok(role) => Ok((StatusCode::OK, Json(role))),
Err(sqlx::Error::RowNotFound) => Err((StatusCode::NOT_FOUND, "Role not found".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}

74
apps/users/src/mail.rs Normal file
View file

@ -0,0 +1,74 @@
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use anyhow::Result;
use std::env;
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from_email: String,
from_name: String,
}
impl Mailer {
pub fn new() -> Self {
let smtp_host = env::var("SMTP_HOST").expect("SMTP_HOST must be set");
let smtp_port = env::var("SMTP_PORT")
.expect("SMTP_PORT must be set")
.parse::<u16>()
.expect("SMTP_PORT must be a number");
let smtp_user = env::var("SMTP_USER").expect("SMTP_USER must be set");
let smtp_pass = env::var("SMTP_PASS").expect("SMTP_PASS must be set");
let from_email = env::var("SMTP_FROM_EMAIL").expect("SMTP_FROM_EMAIL must be set");
let from_name = env::var("SMTP_FROM_NAME").expect("SMTP_FROM_NAME must be set");
let credentials = Credentials::new(smtp_user, smtp_pass);
let transport = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp_host)
.expect("Failed to create SMTP transport")
.port(smtp_port)
.credentials(credentials)
.build();
Self {
transport,
from_email,
from_name,
}
}
pub async fn send_verification_email(&self, to_email: &str, full_name: &str, otp: &str) -> Result<()> {
let body = format!(
"Hello {},\n\nYour verification code for NXTGAUGE is: {}\n\nThis code expires in 15 minutes.\n\nRegards,\nThe NXTGAUGE Team",
full_name, otp
);
let email = Message::builder()
.from(format!("{} <{}>", self.from_name, self.from_email).parse()?)
.to(to_email.parse()?)
.subject("Verify your NXTGAUGE account")
.body(body)?;
self.transport.send(email).await?;
Ok(())
}
pub async fn send_password_reset_email(&self, to_email: &str, full_name: &str, token: &str) -> Result<()> {
let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let reset_link = format!("{}/reset-password?token={}", frontend_url, token);
let body = format!(
"Hello {},\n\nYou requested a password reset. Click the link below to reset your password:\n\n{}\n\nIf you did not request this, please ignore this email.\n\nRegards,\nThe NXTGAUGE Team",
full_name, reset_link
);
let email = Message::builder()
.from(format!("{} <{}>", self.from_name, self.from_email).parse()?)
.to(to_email.parse()?)
.subject("Reset your NXTGAUGE password")
.body(body)?;
self.transport.send(email).await?;
Ok(())
}
}

76
apps/users/src/main.rs Normal file
View file

@ -0,0 +1,76 @@
mod handlers;
mod mail;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use sqlx::PgPool;
use mail::Mailer;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub mail: Arc<Mailer>,
}
#[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 database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = db::establish_connection(&database_url)
.await
.expect("Failed to connect to the database");
tracing::info!("Connected to the database");
let mailer = Arc::new(Mailer::new());
let state = AppState {
pool,
mail: mailer,
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
// ── Auth ─────────────────────────────────────────────────────────
.nest("/api/auth", handlers::auth::router())
// ── Roles & User Self-Service ─────────────────────────────────────
.nest("/api/admin/roles", handlers::roles::router())
// ── Notifications ─────────────────────────────────────────────────
.nest("/api/me/notifications", handlers::notifications::router())
// ── Admin: Onboarding + Dashboard Config ──────────────────────────
.nest("/api/admin/onboarding-config", handlers::config::onboarding_router())
.nest("/api/admin/dashboard-config", handlers::config::dashboard_router())
// ── Public Config ─────────────────────────────────────────────────
.nest("/api/config/onboarding", handlers::config::onboarding_router())
.nest("/api/config/dashboard", handlers::config::dashboard_router())
.nest("/api/runtime-config", handlers::config::runtime_router())
.layer(cors)
.with_state(state);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse()
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Users service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,18 @@
[package]
name = "video_editors"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }

View file

@ -0,0 +1,32 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router};
use sqlx::PgPool;
use db::models::video_editor::{VideoEditorRepository, UpsertVideoEditorProfilePayload};
use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.merge(contracts::profession_shared::shared_routes("VIDEO_EDITOR"))
}
async fn get_profile(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match VideoEditorRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<UpsertVideoEditorProfilePayload>,
) -> impl IntoResponse {
match VideoEditorRepository::upsert(&pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -0,0 +1,42 @@
mod handlers;
use axum::{Router, http::Method};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
#[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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to postgres");
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_origin(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Video Editors Service OK" }))
.nest("/api/video-editors", handlers::router())
.layer(cors)
.with_state(pool);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8089".to_string()).parse().unwrap();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Video Editors service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

16
crates/auth/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2021"
[dependencies]
jsonwebtoken = "9.3"
argon2 = "0.5"
rand_core = { version = "0.6", features = ["std"] }
serde = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
db = { path = "../db" }

23
crates/auth/src/crypto.rs Normal file
View file

@ -0,0 +1,23 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hashed = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?
.to_string();
Ok(hashed)
}
pub fn verify_password(password: &str, hashed_password: &str) -> anyhow::Result<bool> {
let parsed_hash = PasswordHash::new(hashed_password)
.map_err(|e| anyhow::anyhow!("Invalid password hash format: {}", e))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}

55
crates/auth/src/jwt.rs Normal file
View file

@ -0,0 +1,55 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // User ID
pub role_id: Option<String>,
pub exp: usize,
pub iat: usize,
}
pub struct JwtTokens {
pub access_token: String,
pub refresh_token: String,
}
pub fn generate_tokens(
user_id: String,
role_id: Option<String>,
jwt_secret: &str,
) -> anyhow::Result<JwtTokens> {
let now = Utc::now();
let access_exp = now + Duration::minutes(15);
let claims = Claims {
sub: user_id.clone(),
role_id,
iat: now.timestamp() as usize,
exp: access_exp.timestamp() as usize,
};
let access_token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)?;
// Refresh token is just a long random string that we hash and store in DB
let refresh_token = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string();
Ok(JwtTokens {
access_token,
refresh_token: refresh_token.replace("-", ""),
})
}
pub fn verify_access_token(token: &str, jwt_secret: &str) -> anyhow::Result<Claims> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::default(),
)?;
Ok(token_data.claims)
}

3
crates/auth/src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod crypto;
pub mod jwt;
pub mod middleware;

View file

@ -0,0 +1,35 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use axum::async_trait;
pub struct RequireAuth(pub crate::jwt::Claims);
#[async_trait]
impl<S> FromRequestParts<S> for RequireAuth
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.filter(|value| value.starts_with("Bearer "));
let token = match auth_header {
Some(header) => header.trim_start_matches("Bearer "),
None => return Err((StatusCode::UNAUTHORIZED, "Missing Bearer token")),
};
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
match crate::jwt::verify_access_token(token, &jwt_secret) {
Ok(claims) => Ok(RequireAuth(claims)),
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid or expired token")),
}
}
}

View file

@ -0,0 +1,138 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── JWT Claims ────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // user_id (UUID string)
pub email: String,
pub roles: Vec<String>,
pub active_role: String,
pub exp: usize,
pub iat: usize,
}
// ── AuthUser extractor ────────────────────────────────────────────────────────
/// Axum extractor: validates the Bearer token in the Authorization header.
/// Usage: `async fn handler(auth: AuthUser, ...) -> impl IntoResponse`
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: Uuid,
pub email: String,
pub claims: Claims,
}
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// 1. Extract Authorization header
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::MissingToken)?;
// 2. Strip "Bearer " prefix
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AuthError::InvalidToken)?;
// 3. Decode & verify
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-change-me".to_string());
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.map_err(|e| {
tracing::debug!("JWT decode error: {}", e);
AuthError::InvalidToken
})?;
// 4. Parse user_id as UUID
let user_id = Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AuthError::InvalidToken)?;
Ok(AuthUser {
user_id,
email: token_data.claims.email.clone(),
claims: token_data.claims,
})
}
}
// ── Auth Error types ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub enum AuthError {
MissingToken,
InvalidToken,
InsufficientPermissions,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
AuthError::MissingToken => (
StatusCode::UNAUTHORIZED,
"MISSING_TOKEN",
"Authorization header required",
),
AuthError::InvalidToken => (
StatusCode::UNAUTHORIZED,
"INVALID_TOKEN",
"Token is invalid or expired",
),
AuthError::InsufficientPermissions => (
StatusCode::FORBIDDEN,
"INSUFFICIENT_PERMISSIONS",
"You do not have permission to access this resource",
),
};
(
status,
Json(serde_json::json!({ "error": message, "code": code })),
)
.into_response()
}
}
// ── Role guard helper ─────────────────────────────────────────────────────────
/// Returns Ok if the user's active_role matches the expected role key.
/// Otherwise returns AuthError::InsufficientPermissions.
pub fn require_role(auth: &AuthUser, expected_role: &str) -> Result<(), AuthError> {
if auth.claims.active_role == expected_role
|| auth.claims.roles.contains(&expected_role.to_string())
{
Ok(())
} else {
Err(AuthError::InsufficientPermissions)
}
}
/// Returns Ok if the user has the ADMIN role.
pub fn require_admin(auth: &AuthUser) -> Result<(), AuthError> {
if auth.claims.roles.contains(&"ADMIN".to_string()) {
Ok(())
} else {
Err(AuthError::InsufficientPermissions)
}
}

View file

@ -0,0 +1,4 @@
pub mod auth_middleware;
pub mod profession_shared;
pub use auth_middleware::{AuthUser, AuthError, Claims, require_role, require_admin};

View file

@ -0,0 +1,179 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use db::models::professional::ProfessionalRepository;
use db::models::requirement::RequirementRepository;
use db::models::lead_request::{LeadRequestRepository, CreateLeadRequestPayload};
use crate::auth_middleware::AuthUser;
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct LeadRequestPayload {
pub requirement_id: Uuid,
}
pub fn shared_routes(profession_key: &'static str) -> Router<PgPool> {
Router::new()
.route(
"/marketplace",
get(move |state, query| browse_marketplace(state, query, profession_key)),
)
.route("/marketplace/:id", get(get_requirement))
.route("/leads/request", post(send_lead_request))
.route("/leads/requests/me", get(my_requests))
.route("/leads/requests/:id", delete(cancel_request))
.route("/leads/accepted/me", get(accepted_leads))
.route("/leads/accepted/:id", get(accepted_lead_detail))
.route("/portfolio/me", get(list_portfolio))
// ... (other routes remain same for now)
}
async fn browse_marketplace(
State(pool): State<PgPool>,
Query(q): Query<PaginationQuery>,
profession_key: &str,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ProfessionalRepository::get_marketplace(&pool, profession_key, 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 get_requirement(
State(pool): State<PgPool>,
_auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match RequirementRepository::get_by_id(&pool, id).await {
Ok(Some(req)) if req.status == "OPEN" => (StatusCode::OK, Json(req)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Requirement is not open").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn send_lead_request(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<LeadRequestPayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&pool, payload.requirement_id).await {
Ok(Some(r)) if r.status == "OPEN" => r,
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Requirement is not open").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
if req.request_count >= 20 {
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
}
// Check wallet balance
let wallet = match ProfessionalRepository::get_wallet(&pool, auth.user_id).await {
Ok(w) => w,
_ => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
};
if wallet.balance < 25 {
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
}
let db_payload = CreateLeadRequestPayload {
requirement_id: req.id,
professional_id: prof.id,
expires_at: Utc::now() + chrono::Duration::hours(24),
};
match LeadRequestRepository::create(&pool, db_payload).await {
Ok(lead) => {
let _ = RequirementRepository::increment_request_count(&pool, req.id).await;
// TODO: Debit/Reserve Tracecoins in wallet ledger
(StatusCode::CREATED, Json(lead)).into_response()
},
Err(e) => {
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Already requested this lead").into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
}
async fn list_portfolio(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(prof) => {
match ProfessionalRepository::get_portfolio(&pool, prof.id).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
},
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
}
}
async fn list_services(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(prof) => {
match ProfessionalRepository::get_services(&pool, prof.id).await {
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
},
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
}
}
async fn wallet_balance(
State(pool): State<PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
match ProfessionalRepository::get_wallet(&pool, auth.user_id).await {
Ok(w) => (StatusCode::OK, Json(w)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// Stubs for remaining routes (ledger, invoices, etc.)
async fn my_requests(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
async fn cancel_request(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Done"}))) }
async fn accepted_leads(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
async fn accepted_lead_detail(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) }
async fn create_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) }
async fn update_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>, _v: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) }
async fn delete_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) }
async fn create_service(_s: State<PgPool>, _a: AuthUser, _p: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) }
async fn update_service(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>, _v: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) }
async fn delete_service(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) }
async fn wallet_ledger(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
async fn wallet_invoices(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
async fn wallet_invoice_detail(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) }

13
crates/db/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "db"
version = "0.1.0"
edition = "2021"
[dependencies]
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS runtime_configs;
DROP TABLE IF EXISTS dashboard_configs;
DROP TABLE IF EXISTS onboarding_configs;
DROP TABLE IF EXISTS roles;

View file

@ -0,0 +1,43 @@
-- 1. ROLES
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. ONBOARDING CONFIGS
CREATE TABLE IF NOT EXISTS onboarding_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
schema_json JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_active_onboarding_role UNIQUE (role_id, is_active)
);
-- 3. DASHBOARD CONFIGS
CREATE TABLE IF NOT EXISTS dashboard_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL
config_json JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_active_dashboard_role UNIQUE (role_id, is_active)
);
-- 4. RUNTIME CONFIGS
CREATE TABLE IF NOT EXISTS runtime_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
config_json JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_active_runtime_role UNIQUE (role_id, is_active)
);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,23 @@
-- 1. USERS
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, PENDING, SUSPENDED
role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. REFRESH TOKENS
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for fast token lookups
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS photographer_profiles;

View file

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS photographer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Photographer Specific Fields
portfolio_url VARCHAR(255),
equipment_list TEXT,
years_of_experience INT,
hourly_rate DECIMAL(10, 2),
specialties TEXT[], -- e.g., ["wedding", "portrait", "commercial"]
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure a user can only have one photographer profile
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS tutor_profiles;

View file

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS tutor_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Tutor Specific Fields
subjects_taught TEXT[], -- e.g., ["math", "physics", "computer science"]
education_level VARCHAR(255),
certifications TEXT,
years_of_experience INT,
hourly_rate DECIMAL(10, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure a user can only have one tutor profile
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS company_profiles;

View file

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS company_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Company Specific Fields
company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(100),
industry VARCHAR(150),
website_url VARCHAR(255),
employee_count INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure a user can only have one company profile
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS job_seeker_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS job_seeker_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Job Seeker
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS customer_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS customer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Customer
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS makeup_artist_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS makeup_artist_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Makeup Artist
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS developer_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS developer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Developer
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS video_editor_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS video_editor_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Video Editor
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS graphic_designer_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS graphic_designer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Graphic Designer
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS social_media_manager_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS social_media_manager_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Social Media Manager
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS fitness_trainer_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS fitness_trainer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Fitness Trainer
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF NOT EXISTS catering_service_profiles;

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS catering_service_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Generic Fields for Catering Service
bio TEXT,
experience_years INT,
custom_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);

View file

@ -0,0 +1,14 @@
DROP TABLE IF EXISTS submission_documents;
DROP TABLE IF EXISTS onboarding_submissions;
DROP TABLE IF EXISTS employees;
DROP TABLE IF EXISTS designations;
DROP TABLE IF EXISTS departments;
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS user_roles;
ALTER TABLE users
DROP COLUMN IF EXISTS full_name,
DROP COLUMN IF EXISTS phone,
DROP COLUMN IF EXISTS email_verified,
DROP COLUMN IF EXISTS phone_verified,
DROP COLUMN IF EXISTS deleted_at;

View file

@ -0,0 +1,83 @@
-- Add missing columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS full_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS phone VARCHAR(20) UNIQUE,
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- user_roles: many-to-many, a user can hold multiple external roles
CREATE TABLE IF NOT EXISTS user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, role_id)
);
-- role_permissions
CREATE TABLE IF NOT EXISTS role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_key VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(role_id, permission_key)
);
-- departments for internal staff
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- designations for internal staff
CREATE TABLE IF NOT EXISTS designations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- employees (internal staff records)
CREATE TABLE IF NOT EXISTS employees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
role_id UUID NOT NULL REFERENCES roles(id),
department_id UUID REFERENCES departments(id),
designation_id UUID REFERENCES designations(id),
employee_code VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- onboarding_submissions: tracks verification submissions
CREATE TABLE IF NOT EXISTS onboarding_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id),
config_id UUID REFERENCES onboarding_configs(id),
data_json JSONB,
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
submitted_at TIMESTAMPTZ,
reviewed_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES users(id),
rejection_reason TEXT,
document_request TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- submission_documents: uploaded files for onboarding
CREATE TABLE IF NOT EXISTS submission_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES onboarding_submissions(id) ON DELETE CASCADE,
document_type VARCHAR(100) NOT NULL,
file_url VARCHAR(500) NOT NULL,
file_name VARCHAR(255),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status);
CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id);
CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_status ON onboarding_submissions(status);

View file

@ -0,0 +1,24 @@
DROP INDEX IF EXISTS idx_applications_status;
DROP INDEX IF EXISTS idx_applications_job_seeker_id;
DROP INDEX IF EXISTS idx_applications_job_id;
DROP INDEX IF EXISTS idx_jobs_status;
DROP INDEX IF EXISTS idx_jobs_company_id;
DROP TABLE IF EXISTS applications;
DROP TABLE IF EXISTS jobs;
ALTER TABLE company_profiles
DROP COLUMN IF EXISTS business_type,
DROP COLUMN IF EXISTS gst_number,
DROP COLUMN IF EXISTS contact_name,
DROP COLUMN IF EXISTS contact_email,
DROP COLUMN IF EXISTS contact_phone,
DROP COLUMN IF EXISTS address_line1,
DROP COLUMN IF EXISTS city,
DROP COLUMN IF EXISTS state,
DROP COLUMN IF EXISTS country,
DROP COLUMN IF EXISTS postal_code,
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS free_job_slots,
DROP COLUMN IF EXISTS purchased_job_slots,
DROP COLUMN IF EXISTS free_contact_views,
DROP COLUMN IF EXISTS purchased_contact_views;

View file

@ -0,0 +1,61 @@
-- Complete company profile (replacing the minimal stub)
ALTER TABLE company_profiles
ADD COLUMN IF NOT EXISTS business_type VARCHAR(100),
ADD COLUMN IF NOT EXISTS gst_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(20),
ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(500),
ADD COLUMN IF NOT EXISTS city VARCHAR(100),
ADD COLUMN IF NOT EXISTS state VARCHAR(100),
ADD COLUMN IF NOT EXISTS country VARCHAR(100) NOT NULL DEFAULT 'India',
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20),
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN IF NOT EXISTS free_job_slots INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS purchased_job_slots INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS free_contact_views INTEGER NOT NULL DEFAULT 30,
ADD COLUMN IF NOT EXISTS purchased_contact_views INTEGER NOT NULL DEFAULT 0;
-- Jobs
CREATE TABLE IF NOT EXISTS jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
category VARCHAR(100),
description TEXT NOT NULL,
location VARCHAR(255) NOT NULL,
job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, CONTRACT
salary_min INTEGER, -- in paise
salary_max INTEGER, -- in paise
experience_years INTEGER,
skills TEXT[] DEFAULT '{}',
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
-- DRAFT, PENDING_APPROVAL, LIVE, EXPIRED, CLOSED, REJECTED
rejection_reason TEXT,
expires_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Applications (Job Seeker → Job)
CREATE TABLE IF NOT EXISTS applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE,
cover_letter TEXT,
resume_url VARCHAR(500),
status VARCHAR(50) NOT NULL DEFAULT 'APPLIED',
-- APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
contact_viewed BOOLEAN NOT NULL DEFAULT false,
UNIQUE(job_id, job_seeker_id)
);
CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id);
CREATE INDEX IF NOT EXISTS idx_applications_job_seeker_id ON applications(job_seeker_id);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);

View file

@ -0,0 +1,12 @@
DROP INDEX IF EXISTS idx_professionals_profession_key;
DROP INDEX IF EXISTS idx_lead_requests_status;
DROP INDEX IF EXISTS idx_lead_requests_professional_id;
DROP INDEX IF EXISTS idx_lead_requests_requirement_id;
DROP INDEX IF EXISTS idx_requirements_profession_key;
DROP INDEX IF EXISTS idx_requirements_status;
DROP INDEX IF EXISTS idx_requirements_customer_id;
DROP TABLE IF EXISTS lead_requests;
DROP TABLE IF EXISTS professionals;
DROP TABLE IF EXISTS requirements;
ALTER TABLE customer_profiles DROP COLUMN IF EXISTS full_name, DROP COLUMN IF EXISTS phone, DROP COLUMN IF EXISTS city, DROP COLUMN IF EXISTS area, DROP COLUMN IF EXISTS preferred_professions, DROP COLUMN IF EXISTS active_requirement_count, DROP COLUMN IF EXISTS status;
ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS full_name, DROP COLUMN IF EXISTS location, DROP COLUMN IF EXISTS summary, DROP COLUMN IF EXISTS experience_years, DROP COLUMN IF EXISTS skills, DROP COLUMN IF EXISTS resume_url, DROP COLUMN IF EXISTS active_application_count, DROP COLUMN IF EXISTS status;

View file

@ -0,0 +1,79 @@
-- Add missing fields to job_seeker_profiles
ALTER TABLE job_seeker_profiles
ADD COLUMN IF NOT EXISTS full_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS location VARCHAR(255),
ADD COLUMN IF NOT EXISTS summary TEXT,
ADD COLUMN IF NOT EXISTS experience_years INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500),
ADD COLUMN IF NOT EXISTS active_application_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE';
-- Requirements (customer leads)
CREATE TABLE IF NOT EXISTS requirements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE,
profession_key VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
location VARCHAR(255) NOT NULL,
budget INTEGER, -- in paise
preferred_date DATE,
extra_data_json JSONB,
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
-- DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED
rejection_reason TEXT,
request_count INTEGER NOT NULL DEFAULT 0,
accepted_count INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- professionals unified table (parent for all 9 profession subtypes)
CREATE TABLE IF NOT EXISTS professionals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
profession_key VARCHAR(50) NOT NULL,
display_name VARCHAR(255) NOT NULL,
location VARCHAR(255),
bio TEXT,
extra_data_json JSONB,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Lead requests (professional → requirement)
CREATE TABLE IF NOT EXISTS lead_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
-- PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED
tracecoins_reserved INTEGER NOT NULL DEFAULT 25,
expires_at TIMESTAMPTZ NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
UNIQUE(requirement_id, professional_id)
);
-- Add missing fields to customer_profiles
ALTER TABLE customer_profiles
ADD COLUMN IF NOT EXISTS full_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS phone VARCHAR(20),
ADD COLUMN IF NOT EXISTS city VARCHAR(100),
ADD COLUMN IF NOT EXISTS area VARCHAR(100),
ADD COLUMN IF NOT EXISTS preferred_professions TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS active_requirement_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_requirements_customer_id ON requirements(customer_id);
CREATE INDEX IF NOT EXISTS idx_requirements_status ON requirements(status);
CREATE INDEX IF NOT EXISTS idx_requirements_profession_key ON requirements(profession_key);
CREATE INDEX IF NOT EXISTS idx_lead_requests_requirement_id ON lead_requests(requirement_id);
CREATE INDEX IF NOT EXISTS idx_lead_requests_professional_id ON lead_requests(professional_id);
CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status);
CREATE INDEX IF NOT EXISTS idx_professionals_profession_key ON professionals(profession_key);

View file

@ -0,0 +1,98 @@
-- Portfolio items (for professionals)
CREATE TABLE IF NOT EXISTS portfolio_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Portfolio images (multiple images per portfolio item)
CREATE TABLE IF NOT EXISTS portfolio_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE,
file_url VARCHAR(500) NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0
);
-- Services (offered by professionals)
CREATE TABLE IF NOT EXISTS services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
price INTEGER NOT NULL DEFAULT 0, -- in paise
duration_minutes INTEGER,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tracecoin wallets (one per user)
CREATE TABLE IF NOT EXISTS tracecoin_wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
balance INTEGER NOT NULL DEFAULT 0,
reserved INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tracecoin ledger (IMMUTABLE — never update or delete)
CREATE TABLE IF NOT EXISTS tracecoin_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id),
type VARCHAR(20) NOT NULL, -- CREDIT, DEBIT, RESERVE, RELEASE
amount INTEGER NOT NULL,
reason VARCHAR(100) NOT NULL, -- LEAD_REQUEST, LEAD_ACCEPTED, PURCHASE, ADMIN_CREDIT, LEAD_EXPIRED
reference_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Pricing packages (Tracecoin bundles, job slots, contact views)
CREATE TABLE IF NOT EXISTS pricing_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
role_key VARCHAR(50) NOT NULL,
package_type VARCHAR(50) NOT NULL, -- JOB_POSTING, CONTACT_VIEWS, TRACECOIN_BUNDLE
tracecoins_amount INTEGER NOT NULL DEFAULT 0,
price_inr INTEGER NOT NULL, -- in paise
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Payments (Razorpay transactions)
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
package_id UUID NOT NULL REFERENCES pricing_packages(id),
razorpay_order_id VARCHAR(100),
razorpay_payment_id VARCHAR(100),
amount_inr INTEGER NOT NULL,
tracecoins_credited INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SUCCESS, FAILED
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Invoices (generated for every successful payment)
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES payments(id),
user_id UUID NOT NULL REFERENCES users(id),
invoice_number VARCHAR(50) NOT NULL UNIQUE,
subtotal INTEGER NOT NULL, -- in paise
gst_amount INTEGER NOT NULL, -- in paise
total INTEGER NOT NULL, -- in paise
status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', -- ISSUED, PAID
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
file_url VARCHAR(500)
);
CREATE INDEX IF NOT EXISTS idx_portfolio_items_professional_id ON portfolio_items(professional_id);
CREATE INDEX IF NOT EXISTS idx_services_professional_id ON services(professional_id);
CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id);
CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id);
CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id);

View file

@ -0,0 +1,26 @@
-- Notifications (in-app)
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
body TEXT,
type VARCHAR(50), -- APPROVAL, LEAD, JOB, PAYMENT
reference_id UUID,
is_read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Email logs (audit trail)
CREATE TABLE IF NOT EXISTS email_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
trigger VARCHAR(100) NOT NULL, -- PROFILE_APPROVED, JOB_APPROVED, etc.
to_email VARCHAR(255) NOT NULL,
subject VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, FAILED
sent_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read);
CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id);

View file

@ -0,0 +1,19 @@
-- Revert profession-specific tables
DROP TABLE IF EXISTS catering_service_profiles;
DROP TABLE IF EXISTS fitness_trainer_profiles;
DROP TABLE IF EXISTS social_media_manager_profiles;
DROP TABLE IF EXISTS graphic_designer_profiles;
DROP TABLE IF EXISTS video_editor_profiles;
DROP TABLE IF EXISTS developer_profiles;
DROP TABLE IF EXISTS makeup_artist_profiles;
DROP TABLE IF EXISTS tutor_profiles;
DROP TABLE IF EXISTS photographer_profiles;
-- Revert portfolio/services/lead_requests foreign key additions
ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id;
ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key;
ALTER TABLE services DROP COLUMN IF EXISTS user_id;
ALTER TABLE services DROP COLUMN IF EXISTS profession_key;
ALTER TABLE lead_requests DROP COLUMN IF EXISTS professional_user_id;

View file

@ -0,0 +1,218 @@
-- Drop the generic professionals table approach; use per-profession profile tables
-- Portfolio and services stay shared (referenced by user_id + profession_key)
-- 1. PHOTOGRAPHER PROFILES
CREATE TABLE IF NOT EXISTS photographer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
specialties TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Portrait', 'Commercial']
camera_brands TEXT[] DEFAULT '{}', -- e.g. ['Sony', 'Canon']
studio_available BOOLEAN NOT NULL DEFAULT false,
outdoor_shoots BOOLEAN NOT NULL DEFAULT true,
travel_radius_km INTEGER DEFAULT 50,
starting_price_inr INTEGER DEFAULT 0, -- in paise
-- Verification & status
status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. TUTOR PROFILES
CREATE TABLE IF NOT EXISTS tutor_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
subjects TEXT[] DEFAULT '{}', -- e.g. ['Math', 'Physics', 'Hindi']
board_types TEXT[] DEFAULT '{}', -- e.g. ['CBSE', 'ICSE', 'IB']
qualification VARCHAR(255), -- e.g. 'B.Tech IIT Delhi'
teaches_online BOOLEAN NOT NULL DEFAULT true,
teaches_offline BOOLEAN NOT NULL DEFAULT true,
experience_years INTEGER DEFAULT 0,
hourly_rate_inr INTEGER DEFAULT 0, -- in paise
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. MAKEUP ARTIST PROFILES
CREATE TABLE IF NOT EXISTS makeup_artist_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
specializations TEXT[] DEFAULT '{}', -- e.g. ['Bridal', 'Editorial', 'SFX']
kit_brands TEXT[] DEFAULT '{}', -- e.g. ['MAC', 'NARS', 'NYX']
home_service BOOLEAN NOT NULL DEFAULT true,
studio_available BOOLEAN NOT NULL DEFAULT false,
starting_price_inr INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. DEVELOPER PROFILES
CREATE TABLE IF NOT EXISTS developer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
tech_stack TEXT[] DEFAULT '{}', -- e.g. ['Rust', 'React', 'PostgreSQL']
github_url VARCHAR(500),
portfolio_url VARCHAR(500),
experience_years INTEGER DEFAULT 0,
availability VARCHAR(50) DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, FREELANCE
hourly_rate_inr INTEGER DEFAULT 0,
remote_ok BOOLEAN NOT NULL DEFAULT true,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 5. VIDEO EDITOR PROFILES
CREATE TABLE IF NOT EXISTS video_editor_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
software_skills TEXT[] DEFAULT '{}', -- e.g. ['Premiere Pro', 'DaVinci Resolve']
style_tags TEXT[] DEFAULT '{}', -- e.g. ['Cinematic', 'Corporate', 'Reels']
turnaround_days INTEGER DEFAULT 7,
reel_url VARCHAR(500),
starting_price_inr INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 6. GRAPHIC DESIGNER PROFILES
CREATE TABLE IF NOT EXISTS graphic_designer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
design_tools TEXT[] DEFAULT '{}', -- e.g. ['Figma', 'Illustrator', 'Photoshop']
style_tags TEXT[] DEFAULT '{}', -- e.g. ['Minimalist', 'Bold', 'Corporate']
brand_experience BOOLEAN NOT NULL DEFAULT false,
portfolio_url VARCHAR(500),
starting_price_inr INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 7. SOCIAL MEDIA MANAGER PROFILES
CREATE TABLE IF NOT EXISTS social_media_manager_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'LinkedIn', 'YouTube']
industries TEXT[] DEFAULT '{}', -- e.g. ['F&B', 'Fashion', 'Real Estate']
content_types TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Carousels', 'Stories']
avg_follower_growth_pct INTEGER DEFAULT 0,
starting_price_inr INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 8. FITNESS TRAINER PROFILES
CREATE TABLE IF NOT EXISTS fitness_trainer_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
disciplines TEXT[] DEFAULT '{}', -- e.g. ['Yoga', 'HIIT', 'Zumba', 'CrossFit']
certifications TEXT[] DEFAULT '{}', -- e.g. ['ACE', 'NASM', 'Yoga Alliance RYT']
online_sessions BOOLEAN NOT NULL DEFAULT true,
home_visits BOOLEAN NOT NULL DEFAULT false,
gym_based BOOLEAN NOT NULL DEFAULT false,
per_session_rate_inr INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 9. CATERING SERVICES PROFILES
CREATE TABLE IF NOT EXISTS catering_service_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
business_name VARCHAR(255) NOT NULL,
bio TEXT,
location VARCHAR(255),
-- Profession-specific
cuisine_types TEXT[] DEFAULT '{}', -- e.g. ['North Indian', 'Continental', 'Vegan']
event_types TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Corporate', 'Birthday']
min_guests INTEGER DEFAULT 10,
max_guests INTEGER DEFAULT 500,
has_setup_team BOOLEAN NOT NULL DEFAULT true,
has_serving_staff BOOLEAN NOT NULL DEFAULT true,
price_per_head_inr INTEGER DEFAULT 0, -- in paise
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Shared: portfolio_items now uses user_id + profession_key (no foreign key to professionals)
-- Drop the professionals-table FK if it was added before
ALTER TABLE portfolio_items
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50);
ALTER TABLE services
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50);
-- Lead requests: use user_id instead of professional_id foreign key
ALTER TABLE lead_requests
ADD COLUMN IF NOT EXISTS professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_photographer_profiles_status ON photographer_profiles(status);
CREATE INDEX IF NOT EXISTS idx_tutor_profiles_status ON tutor_profiles(status);
CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_status ON makeup_artist_profiles(status);
CREATE INDEX IF NOT EXISTS idx_developer_profiles_status ON developer_profiles(status);
CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_status ON video_editor_profiles(status);
CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_status ON graphic_designer_profiles(status);
CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_status ON social_media_manager_profiles(status);
CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_status ON fitness_trainer_profiles(status);
CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_status ON catering_service_profiles(status);
CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id);
CREATE INDEX IF NOT EXISTS idx_services_user_id ON services(user_id);

View file

@ -0,0 +1,10 @@
-- Remove email verification and password reset columns from users table
ALTER TABLE users
DROP COLUMN IF EXISTS email_verification_token,
DROP COLUMN IF EXISTS email_verification_expires_at,
DROP COLUMN IF EXISTS reset_password_token,
DROP COLUMN IF EXISTS reset_password_expires_at;
-- Remove indices
DROP INDEX IF EXISTS idx_users_email_verification_token;
DROP INDEX IF EXISTS idx_users_reset_password_token;

View file

@ -0,0 +1,10 @@
-- Add email verification and password reset columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255),
ADD COLUMN IF NOT EXISTS email_verification_expires_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reset_password_token VARCHAR(255),
ADD COLUMN IF NOT EXISTS reset_password_expires_at TIMESTAMPTZ;
-- Add index for token lookups
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
CREATE INDEX IF NOT EXISTS idx_users_reset_password_token ON users(reset_password_token);

11
crates/db/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
pub mod models;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn establish_connection(database_url: &str) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(20)
.connect(database_url)
.await
}

Some files were not shown because too many files have changed in this diff Show more